feat(analytics): M2 self-improving chatbot analytics

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)
This commit is contained in:
Moreminimore
2026-08-25 15:56:11 +07:00
parent 02ae690a87
commit b86b0c59f6
22 changed files with 2095 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
# Admin-only M2 analytics reporting endpoints (phase 2).
#
# GET /api/v2/accounts/:account_id/analytics_reports/summary
# -> Analytics::ReportService (immutable daily-metric snapshots)
# GET /api/v2/accounts/:account_id/analytics_reports/drilldown
# -> Analytics::DrilldownService (live conversations, filterable)
#
# Both are admin-role only (ReportPolicy#view? => administrator?).
class Api::V2::Accounts::AnalyticsReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def summary
render json: Analytics::ReportService.build(
account: Current.account,
since: date_param(:since),
until_date: date_param(:until)
)
end
def drilldown
render json: Analytics::DrilldownService.build(
account: Current.account,
filters: drilldown_filters
)
end
def persona_evaluation
result = Analytics::WeeklyPersonaEvaluator.evaluate(account: Current.account)
render json: {
summary: result.summary,
recommendations: result.recommendations,
disabled: result.disabled?,
error: result.error
}
end
# Run the evaluation and deliver it via the configured channel (LINE / webhook).
# POST /analytics_reports/persona_evaluation/deliver
def persona_evaluation_deliver
result = Analytics::WeeklyPersonaEvaluator.evaluate(account: Current.account)
return render json: { disabled: true } if result.disabled?
delivery = Analytics::PersonaApprovalService.deliver(account: Current.account, evaluation: result)
render json: {
summary: result.summary,
recommendations: result.recommendations,
delivery: delivery
}
end
# Save the delivery configuration (admin LINE user id / Telegram chat id + webhook URL/secret).
# POST /analytics_reports/persona_approval_settings
# { line_user_id, telegram_chat_id, webhook_url, webhook_secret }
def persona_approval_settings
account = Current.account
account.custom_attributes['persona_line_user_id'] = params[:line_user_id] if params.key?(:line_user_id)
account.custom_attributes['persona_telegram_chat_id'] = params[:telegram_chat_id] if params.key?(:telegram_chat_id)
account.custom_attributes['persona_webhook_url'] = params[:webhook_url] if params.key?(:webhook_url)
account.custom_attributes['persona_webhook_secret'] = params[:webhook_secret] if params[:webhook_secret].present?
account.save!
render json: { success: true }
end
# Receive an admin decision (approve / reject / view_full_prompt) — from a LINE
# callback or webhook echo. On approve, notify the configured webhook with the
# decision attached (webhook-only, NOT re-sent over LINE).
# POST /analytics_reports/persona_decision { decision }
def persona_decision
decision = params[:decision].to_s
raise ActionController::BadRequest, 'invalid decision' unless %w[approve reject view_full_prompt].include?(decision)
if decision == 'approve'
track_approval(decision)
end
render json: { success: true, decision: decision }
end
private
def track_approval(decision)
evaluation = Analytics::WeeklyPersonaEvaluator.evaluate(account: Current.account)
return if evaluation.disabled?
Analytics::PersonaApprovalService.notify_approval(
account: Current.account,
evaluation: evaluation,
decision: decision
)
end
def check_authorization
authorize :report, :view?
end
def date_param(name)
value = params[name]
return nil if value.blank?
Date.parse(value.to_s)
rescue ArgumentError
raise ActionController::BadRequest, "Invalid #{name} date"
end
def drilldown_filters
{
since: date_param(:since),
until: date_param(:until),
agent_id: integer_param(:agent_id),
team_id: integer_param(:team_id),
inbox_id: integer_param(:inbox_id),
channel: params[:channel].presence,
tag: params[:tag].presence,
deal: params[:deal].presence,
page: integer_param(:page),
per_page: integer_param(:per_page)
}.compact
end
def integer_param(name)
value = params[name]
return nil if value.blank?
Integer(value)
rescue ArgumentError
raise ActionController::BadRequest, "Invalid #{name}"
end
end

View File

@@ -0,0 +1,54 @@
# Admin-only product catalog management (phase 3).
#
# GET /api/v2/accounts/:account_id/product_catalog_entries
# -> list current catalog entries
# POST /api/v2/accounts/:account_id/product_catalog_entries/import
# -> import from pasted text (copy/paste), CSV/TSV upload, or an .xlsx/.ods/.csv
# file upload (multipart `file`); text via `content`
#
# Admin-role only (ReportPolicy#view? => administrator?).
class Api::V2::Accounts::ProductCatalogEntriesController < Api::V1::Accounts::BaseController
before_action :check_authorization
SUPPORTED_SPREADSHEET_EXTS = %w[xlsx xls ods csv].freeze
def index
entries = Current.account.product_catalog_entries
.order(:group_name, :subgroup_name, :product_name)
render json: { entries: entries.map(&:as_json) }
end
def import
if params[:file].present?
import_from_file(params[:file])
else
import_from_content(params[:content])
end
end
private
def import_from_content(content)
raise ActionController::BadRequest, 'content is required' if content.blank?
result = Analytics::ProductCatalogImportService.import(account: Current.account, content: content)
render json: result
end
def import_from_file(file)
ext = File.extname(file.original_filename.to_s).delete('.').downcase
raise ActionController::BadRequest, "unsupported file type: .#{ext}" unless SUPPORTED_SPREADSHEET_EXTS.include?(ext)
path = file.tempfile.path
result = Analytics::ProductCatalogImportService.import_file(
account: Current.account,
file_path: path,
filename: file.original_filename
)
render json: result
end
def check_authorization
authorize :report, :view?
end
end

View File

@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from './ApiClient';
class AnalyticsReportsAPI extends ApiClient {
constructor() {
super('analytics_reports', { accountScoped: true, apiVersion: 'v2' });
}
getSummary(params = {}) {
return axios.get(`${this.url}/summary`, { params });
}
getDrilldown(params = {}) {
return axios.get(`${this.url}/drilldown`, { params });
}
}
export default new AnalyticsReportsAPI();

View File

@@ -0,0 +1,26 @@
/* global axios */
import ApiClient from './ApiClient';
class ProductCatalogAPI extends ApiClient {
constructor() {
super('product_catalog_entries', { accountScoped: true, apiVersion: 'v2' });
}
list() {
return axios.get(this.url, { params: { page: 1 } });
}
importFromContent(content) {
return axios.post(`${this.url}/import`, { content });
}
importFromFile(file) {
const formData = new FormData();
formData.append('file', file);
return axios.post(`${this.url}/import`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
}
export default new ProductCatalogAPI();

View File

@@ -541,6 +541,61 @@
}
}
},
"PRODUCT_CATALOG": {
"TITLE": "Product Catalog",
"DESC": "Import your product catalog so the chatbot can tag conversations with the right product.",
"PASTE_TITLE": "Paste catalog",
"IMPORT_BUTTON": "Import text",
"UPLOAD_BUTTON": "Upload .xlsx / .csv",
"IMPORTED": "imported",
"UPDATED": "updated",
"CURRENT_TITLE": "Current catalog",
"COL_GROUP": "Group",
"COL_SUBGROUP": "Subgroup",
"COL_PRODUCT": "Product",
"COL_ALIASES": "Aliases",
"EMPTY": "No products in the catalog yet.",
"LOAD_FAILED": "Failed to load catalog",
"IMPORT_FAILED": "Import failed",
"FILE_IMPORT_FAILED": "File import failed",
"FORMAT_HINT": "group | subgroup | product | display | aliases",
"FORMAT_EXAMPLE": "กลุ่มหลัก | กลุ่มย่อย | ชื่อสินค้า | ชื่อแสดง | สินค้า1,สินค้า2",
"LINE": "Line"
},
"ANALYTICS_REPORTS": {
"HEADER": "Analytics",
"HEADER_DESC": "Self-improving chatbot insights",
"FROM": "From",
"TO": "To",
"AGENT": "Agent ID",
"TEAM": "Team ID",
"INBOX": "Inbox ID",
"CHANNEL": "Channel",
"TAG": "Tag",
"DEAL": "Deal",
"ALL_CHANNELS": "All channels",
"ALL_DEALS": "All deals",
"APPLY": "Apply",
"RESET": "Reset",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages",
"RESOLVED": "Resolved",
"UNRESOLVED": "Unresolved",
"DEALS_WON": "Deals won",
"CONVERSATION_DRILLDOWN": "Conversation drilldown",
"NO_DATA": "No conversations match the current filters.",
"TOTAL": "{count} total",
"COL_ID": "ID",
"COL_CHANNEL": "Channel",
"COL_ASSIGNEE": "Assignee",
"COL_STATUS": "Status",
"COL_DEAL": "Deal",
"COL_MESSAGES": "Messages",
"COL_TAGS": "Tags",
"OPT_WON": "Won",
"OPT_LOST": "Lost",
"OPT_UNDECIDED": "Undecided"
},
"OVERVIEW_REPORTS": {
"HEADER": "Overview",
"LIVE": "Live",

View File

@@ -0,0 +1,175 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import ProductCatalogAPI from 'dashboard/api/productCatalog';
const { t } = useI18n();
const entries = ref([]);
const loading = ref(false);
const importResult = ref(null);
const pastedContent = ref('');
const fileInput = ref(null);
const PASTE_HINT = `${t('PRODUCT_CATALOG.FORMAT_HINT')}\n${t('PRODUCT_CATALOG.FORMAT_EXAMPLE')}`;
async function loadEntries() {
loading.value = true;
try {
const { data } = await ProductCatalogAPI.list();
entries.value = data.entries || [];
} catch (error) {
useAlert(t('PRODUCT_CATALOG.LOAD_FAILED'));
} finally {
loading.value = false;
}
}
async function importContent() {
if (!pastedContent.value.trim()) return;
loading.value = true;
try {
const { data } = await ProductCatalogAPI.importFromContent(
pastedContent.value
);
importResult.value = data;
useAlert(
`${data.imported} ${t('PRODUCT_CATALOG.IMPORTED')}, ${data.updated} ${t('PRODUCT_CATALOG.UPDATED')}`
);
await loadEntries();
} catch (error) {
useAlert(t('PRODUCT_CATALOG.IMPORT_FAILED'));
} finally {
loading.value = false;
}
}
async function onFileChange(event) {
const file = event.target.files[0];
if (!file) return;
loading.value = true;
try {
const { data } = await ProductCatalogAPI.importFromFile(file);
importResult.value = data;
useAlert(
`${data.imported} ${t('PRODUCT_CATALOG.IMPORTED')}, ${data.updated} ${t('PRODUCT_CATALOG.UPDATED')}`
);
await loadEntries();
} catch (error) {
useAlert(t('PRODUCT_CATALOG.FILE_IMPORT_FAILED'));
} finally {
loading.value = false;
if (fileInput.value) fileInput.value.value = '';
}
}
onMounted(loadEntries);
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<header>
<h2 class="text-2xl font-semibold">{{ $t('PRODUCT_CATALOG.TITLE') }}</h2>
<p class="text-sm text-slate-500">
{{ $t('PRODUCT_CATALOG.DESC') }}
</p>
</header>
<!-- Paste import -->
<section
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<h3 class="text-base font-semibold mb-2">
{{ $t('PRODUCT_CATALOG.PASTE_TITLE') }}
</h3>
<p class="text-xs text-slate-400 mb-2">{{ PASTE_HINT }}</p>
<textarea
v-model="pastedContent"
rows="8"
class="w-full text-sm p-3 rounded-md border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 font-mono"
:placeholder="PASTE_HINT"
/>
<div class="flex gap-2 mt-3">
<button
class="button button--small bg-slate-800 text-white"
:disabled="loading"
@click="importContent"
>
{{ $t('PRODUCT_CATALOG.IMPORT_BUTTON') }}
</button>
<label class="button button--small cursor-pointer">
{{ $t('PRODUCT_CATALOG.UPLOAD_BUTTON') }}
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls,.ods,.csv"
class="hidden"
@change="onFileChange"
/>
</label>
</div>
</section>
<!-- Import result -->
<section
v-if="importResult"
class="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950 p-4"
>
<div class="text-sm font-medium text-emerald-700 dark:text-emerald-300">
{{ importResult.imported }} {{ $t('PRODUCT_CATALOG.IMPORTED') }} ·
{{ importResult.updated }} {{ $t('PRODUCT_CATALOG.UPDATED') }}
</div>
<ul
v-if="importResult.errors && importResult.errors.length"
class="mt-2 text-xs text-red-600"
>
<li v-for="err in importResult.errors" :key="err.line">
{{ $t('PRODUCT_CATALOG.LINE') }} {{ err.line }}: {{ err.message }}
</li>
</ul>
</section>
<!-- List -->
<section
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<h3 class="text-base font-semibold mb-2">
{{ $t('PRODUCT_CATALOG.CURRENT_TITLE') }} ({{ entries.length }})
</h3>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr
class="text-left text-xs text-slate-500 border-b border-slate-200 dark:border-slate-700"
>
<th class="py-2 pr-3">{{ $t('PRODUCT_CATALOG.COL_GROUP') }}</th>
<th class="py-2 pr-3">
{{ $t('PRODUCT_CATALOG.COL_SUBGROUP') }}
</th>
<th class="py-2 pr-3">{{ $t('PRODUCT_CATALOG.COL_PRODUCT') }}</th>
<th class="py-2">{{ $t('PRODUCT_CATALOG.COL_ALIASES') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="e in entries"
:key="e.id"
class="border-b border-slate-100 dark:border-slate-800"
>
<td class="py-2 pr-3">{{ e.group_name }}</td>
<td class="py-2 pr-3">{{ e.subgroup_name || '-' }}</td>
<td class="py-2 pr-3">{{ e.product_name }}</td>
<td class="py-2 text-xs">{{ (e.aliases || []).join(', ') }}</td>
</tr>
<tr v-if="!entries.length && !loading">
<td colspan="4" class="py-6 text-center text-slate-400">
{{ $t('PRODUCT_CATALOG.EMPTY') }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,18 @@
import { frontendURL } from '../../../../helper/URLHelper';
import ProductCatalogIndex from './Index.vue';
const meta = {
permissions: ['administrator'],
};
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/productCatalog'),
name: 'product_catalog_index',
meta,
component: ProductCatalogIndex,
},
],
};

View File

@@ -0,0 +1,287 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useAlert } from 'dashboard/composables';
import AnalyticsReportsAPI from 'dashboard/api/analyticsReports';
import ReportHeader from './components/ReportHeader.vue';
const loading = ref(false);
const summary = ref(null);
const drilldown = ref(null);
const filter = ref({
since: '',
until: '',
agentId: '',
teamId: '',
inboxId: '',
channel: '',
tag: '',
deal: '',
});
const DEAL_OPTIONS = [
{ label: 'OPT_WON', value: 'won' },
{ label: 'OPT_LOST', value: 'lost' },
{ label: 'OPT_UNDECIDED', value: 'undecided' },
];
const CHANNEL_OPTIONS = [
'Channel::WebWidget',
'Channel::Whatsapp',
'Channel::FacebookPage',
'Channel::Instagram',
'Channel::Line',
'Channel::Telegram',
'Channel::Email',
];
async function loadSummary() {
const params = {};
if (filter.value.since) params.since = filter.value.since;
if (filter.value.until) params.until = filter.value.until;
summary.value = (await AnalyticsReportsAPI.getSummary(params)).data;
}
async function loadDrilldown() {
const params = {};
if (filter.value.since) params.since = filter.value.since;
if (filter.value.until) params.until = filter.value.until;
if (filter.value.agentId) params.agent_id = filter.value.agentId;
if (filter.value.teamId) params.team_id = filter.value.teamId;
if (filter.value.inboxId) params.inbox_id = filter.value.inboxId;
if (filter.value.channel) params.channel = filter.value.channel;
if (filter.value.tag) params.tag = filter.value.tag;
if (filter.value.deal) params.deal = filter.value.deal;
drilldown.value = (await AnalyticsReportsAPI.getDrilldown(params)).data;
}
async function loadAll() {
loading.value = true;
try {
await Promise.all([loadSummary(), loadDrilldown()]);
} catch (error) {
useAlert('Failed to load analytics');
} finally {
loading.value = false;
}
}
function resetFilters() {
filter.value = {
since: '',
until: '',
agentId: '',
teamId: '',
inboxId: '',
channel: '',
tag: '',
deal: '',
};
loadAll();
}
onMounted(loadAll);
</script>
<template>
<div>
<ReportHeader
:header-title="$t('ANALYTICS_REPORTS.HEADER')"
:header-description="$t('ANALYTICS_REPORTS.HEADER_DESC')"
/>
<div class="flex flex-col gap-4 pb-6">
<!-- Filters -->
<div
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4 flex flex-wrap gap-3 items-end"
>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.FROM') }}
<input v-model="filter.since" type="date" class="input" />
</label>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.TO') }}
<input v-model="filter.until" type="date" class="input" />
</label>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.AGENT') }}
<input v-model="filter.agentId" type="number" class="input w-24" />
</label>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.TEAM') }}
<input v-model="filter.teamId" type="number" class="input w-24" />
</label>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.INBOX') }}
<input v-model="filter.inboxId" type="number" class="input w-24" />
</label>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.CHANNEL') }}
<select v-model="filter.channel" class="input">
<option value="">{{ $t('ANALYTICS_REPORTS.ALL_CHANNELS') }}</option>
<option v-for="ch in CHANNEL_OPTIONS" :key="ch" :value="ch">
{{ ch }}
</option>
</select>
</label>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.TAG') }}
<input v-model="filter.tag" type="text" class="input w-32" />
</label>
<label
class="flex flex-col gap-1 text-xs text-slate-600 dark:text-slate-300"
>
{{ $t('ANALYTICS_REPORTS.DEAL') }}
<select v-model="filter.deal" class="input">
<option value="">{{ $t('ANALYTICS_REPORTS.ALL_DEALS') }}</option>
<option v-for="d in DEAL_OPTIONS" :key="d.value" :value="d.value">
{{ $t(`ANALYTICS_REPORTS.${d.label}`) }}
</option>
</select>
</label>
<div class="flex gap-2">
<button
class="button button--small bg-slate-800 text-white"
:disabled="loading"
@click="loadAll"
>
{{ $t('ANALYTICS_REPORTS.APPLY') }}
</button>
<button class="button button--small" @click="resetFilters">
{{ $t('ANALYTICS_REPORTS.RESET') }}
</button>
</div>
</div>
<!-- Summary cards -->
<div
v-if="summary && !loading"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4"
>
<div
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<div class="text-xs text-slate-500">
{{ $t('ANALYTICS_REPORTS.CONVERSATIONS') }}
</div>
<div class="text-2xl font-bold">{{ summary.conversation_count }}</div>
</div>
<div
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<div class="text-xs text-slate-500">
{{ $t('ANALYTICS_REPORTS.MESSAGES') }}
</div>
<div class="text-2xl font-bold">{{ summary.message_count }}</div>
</div>
<div
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<div class="text-xs text-slate-500">
{{ $t('ANALYTICS_REPORTS.RESOLVED') }}
</div>
<div class="text-2xl font-bold text-emerald-600">
{{ summary.resolved_count }}
</div>
</div>
<div
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<div class="text-xs text-slate-500">
{{ $t('ANALYTICS_REPORTS.UNRESOLVED') }}
</div>
<div class="text-2xl font-bold text-amber-600">
{{ summary.unresolved_count }}
</div>
</div>
<div
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<div class="text-xs text-slate-500">
{{ $t('ANALYTICS_REPORTS.DEALS_WON') }}
</div>
<div class="text-2xl font-bold text-emerald-600">
{{ summary.deal_outcomes?.won || 0 }}
</div>
</div>
</div>
<!-- Drilldown table -->
<div
class="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"
>
<div class="flex justify-between items-center mb-3">
<h3 class="text-base font-semibold">
{{ $t('ANALYTICS_REPORTS.CONVERSATION_DRILLDOWN') }}
</h3>
<span v-if="drilldown" class="text-xs text-slate-500">{{
$t('ANALYTICS_REPORTS.TOTAL', { count: drilldown.total })
}}</span>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr
class="text-left text-xs text-slate-500 border-b border-slate-200 dark:border-slate-800"
>
<th class="py-2 pr-3">{{ $t('ANALYTICS_REPORTS.COL_ID') }}</th>
<th class="py-2 pr-3">
{{ $t('ANALYTICS_REPORTS.COL_CHANNEL') }}
</th>
<th class="py-2 pr-3">
{{ $t('ANALYTICS_REPORTS.COL_ASSIGNEE') }}
</th>
<th class="py-2 pr-3">
{{ $t('ANALYTICS_REPORTS.COL_STATUS') }}
</th>
<th class="py-2 pr-3">
{{ $t('ANALYTICS_REPORTS.COL_DEAL') }}
</th>
<th class="py-2 pr-3">
{{ $t('ANALYTICS_REPORTS.COL_MESSAGES') }}
</th>
<th class="py-2">{{ $t('ANALYTICS_REPORTS.COL_TAGS') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="c in drilldown?.conversations || []"
:key="c.id"
class="border-b border-slate-100 dark:border-slate-800"
>
<td class="py-2 pr-3">#{{ c.display_id }}</td>
<td class="py-2 pr-3">{{ c.channel }}</td>
<td class="py-2 pr-3">{{ c.assignee_id || '-' }}</td>
<td class="py-2 pr-3 capitalize">{{ c.status }}</td>
<td class="py-2 pr-3">
{{ c.label_list.find(t => t.startsWith('deal:')) || '-' }}
</td>
<td class="py-2 pr-3">{{ c.message_count }}</td>
<td class="py-2 text-xs">{{ c.label_list.join(', ') }}</td>
</tr>
<tr v-if="drilldown && !drilldown.conversations.length">
<td colspan="7" class="py-6 text-center text-slate-400">
{{ $t('ANALYTICS_REPORTS.NO_DATA') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>

View File

@@ -23,6 +23,7 @@ import CsatResponses from './CsatResponses.vue';
import BotReports from './BotReports.vue';
import LiveReports from './LiveReports.vue';
import SLAReports from './SLAReports.vue';
import AnalyticsReports from './AnalyticsReports.vue';
const meta = {
featureFlag: FEATURE_FLAGS.REPORTS,
@@ -152,6 +153,12 @@ export default {
meta,
component: BotReports,
},
{
path: 'analytics',
name: 'analytics_reports',
meta,
component: AnalyticsReports,
},
],
},
],

View File

@@ -28,6 +28,7 @@ import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
import data from './data/data.routes';
import productCatalog from './productCatalog/productCatalog.routes';
export default {
routes: [
@@ -71,5 +72,6 @@ export default {
...security.routes,
...conversationWorkflow.routes,
...captain.routes,
...productCatalog.routes,
],
};

View File

@@ -0,0 +1,35 @@
# Daily analytics aggregation driver (part2b-iii).
#
# Runs once daily (see config/schedule.yml) and pushes each account's closed-day
# conversation metrics through Analytics::AccountDailyProcessor: classify every
# active conversation via the LLM, write the classification tags onto the live
# conversation (re-tagging prior M2-managed tags), and aggregate into the
# immutable daily metrics tables.
#
# One account failing (e.g. an LLM/credential error) never blocks the others.
class Analytics::DailyMetricsJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_each do |account|
process_account(account)
rescue StandardError => e
Rails.logger.error("[Analytics::DailyMetricsJob] account=#{account.id} #{e.class}: #{e.message}")
end
end
private
def process_account(account)
date = closed_day_in_account_tz(account)
Analytics::AccountDailyProcessor.perform(account: account, date: date)
end
# The most recent fully-completed day in the account's reporting timezone.
def closed_day_in_account_tz(account)
tz = account.reporting_timezone.presence
zone = tz ? ActiveSupport::TimeZone[tz] : Time.zone
current_in_zone = zone ? Time.current.in_time_zone(zone) : Time.current
(current_in_zone - 1.day).to_date
end
end

View File

@@ -0,0 +1,23 @@
# Weekly driver: evaluates the persona for every account and delivers the summary
# via Analytics::PersonaApprovalService (LINE primary, webhook fallback). Runs each
# Monday 10:00 (see config/schedule.yml). Per-account failures are contained.
class Analytics::WeeklyPersonaEvaluationJob < ApplicationJob
queue_as :default
def perform
Account.find_each do |account|
evaluate_and_deliver(account)
rescue StandardError => e
Rails.logger.error("[WeeklyPersonaEvaluation] account=#{account.id} #{e.class}: #{e.message}")
end
end
private
def evaluate_and_deliver(account)
evaluation = Analytics::WeeklyPersonaEvaluator.evaluate(account: account)
return if evaluation.disabled?
Analytics::PersonaApprovalService.deliver(account: account, evaluation: evaluation)
end
end

View File

@@ -0,0 +1,218 @@
# Aggregates one account's conversation activity into the immutable daily metrics
# (conversation_daily_metrics + customer_daily_metrics) for a single date in the
# account's reporting timezone, and writes the LLM classification tags onto the
# live conversations.
#
# Flow per account/date:
# 1. Compute the UTC day window from account.reporting_timezone (default UTC).
# 2. Select conversations with activity inside that window.
# 3. For each conversation, classify via Llm::AnalyticsClassifier (skipped when
# disabled), re-tag it (replace prior M2-managed tags, keep manual labels),
# and collect per-conversation / per-customer metrics.
# 4. Upsert conversation_daily_metric (unique account+date) and
# customer_daily_metric (unique account+contact+date).
#
# Daily snapshots are immutable: counts captured here are a point-in-time view; the
# live conversation tags may be re-tagged later without changing historical snapshots.
class Analytics::AccountDailyProcessor
TOPIC_TAG_PREFIX = 'topic:'.freeze
DEAL_TAG_PREFIX = 'deal:'.freeze
# @param account [Account]
# @param date [Date] the reporting date in the account's timezone
def self.perform(account:, date:)
new(account: account, date: date).perform
end
def initialize(account:, date:)
@account = account
@date = date
end
def perform
return unless scope_enabled?
window = day_window
conversations = active_conversations(window)
return if conversations.empty?
catalog_paths = catalog_paths
metrics = build_metrics(conversations, window, catalog_paths)
upsert_metrics(metrics)
end
private
def scope_enabled?
@account.reporting_timezone.blank? || ActiveSupport::TimeZone[@account.reporting_timezone].present?
end
def timezone
@timezone ||= ActiveSupport::TimeZone[@account.reporting_timezone] || Time.zone
end
# [start_utc, end_utc) covering the account-local date.
def day_window
start_tz = timezone.parse(@date.to_s)
end_tz = timezone.parse((@date + 1).to_s)
[start_tz.utc, end_tz.utc]
end
# Conversations with activity (created or last activity) inside the account-local day.
def active_conversations(window)
start_utc, end_utc = window
@account.conversations
.where('created_at < ?', end_utc)
.where('created_at >= ? OR last_activity_at >= ?', start_utc, start_utc)
end
# All tag-hierarchy paths in the account's catalog — used to detect & replace
# previously-written product tags on re-classification.
def catalog_paths
@account.product_catalog_entries
.order(:group_name, :subgroup_name, :product_name)
.map(&:tag_hierarchy)
.map { |parts| parts.join('>') }
end
def build_metrics(conversations, _window, catalog_paths)
{
conversation: {
message_count: 0,
conversation_count: 0,
resolved_count: 0,
unresolved_count: 0,
top_tags: [],
sale_tags: [],
deal_outcomes: {},
agent_breakdown: Hash.new(0),
team_breakdown: Hash.new(0),
channel_breakdown: Hash.new(0),
inbox_breakdown: Hash.new(0)
},
customers: {}
}.tap do |acc|
conversations.each { |conversation| accumulate(acc, conversation, catalog_paths) }
end
end
def accumulate(acc, conversation, catalog_paths)
classification = Llm::AnalyticsClassifier.classify(account: @account, conversation: conversation)
return if classification.error.present? || classification.disabled?
apply_tags(conversation, classification, catalog_paths)
cm = acc[:conversation]
cm[:conversation_count] += 1
message_count = chat_message_count(conversation)
cm[:message_count] += message_count
if conversation.resolved?
cm[:resolved_count] += 1
else
cm[:unresolved_count] += 1
end
cm[:top_tags] |= (classification.topics + classification.products).compact
cm[:sale_tags] |= classification.products
cm[:deal_outcomes]['totals'] ||= {}
cm[:deal_outcomes]['totals'][classification.deal] = cm[:deal_outcomes]['totals'][classification.deal].to_i + 1
cm[:agent_breakdown][conversation.assignee_id] += 1 if conversation.assignee_id
cm[:team_breakdown][conversation.team_id] += 1 if conversation.team_id
cm[:channel_breakdown][channel_key(conversation)] += 1
cm[:inbox_breakdown][conversation.inbox_id] += 1 if conversation.inbox_id
customer_key = conversation.contact_id
customer = acc[:customers][customer_key] ||= {
message_count: 0, conversation_count: 0, resolved_count: 0, unresolved_count: 0,
top_tags: [], deal_outcomes: {}, agent_ids: []
}
customer[:conversation_count] += 1
customer[:message_count] += message_count
if conversation.resolved?
customer[:resolved_count] += 1
else
customer[:unresolved_count] += 1
end
customer[:top_tags] |= (classification.topics + classification.products).compact
customer[:deal_outcomes][classification.deal] = customer[:deal_outcomes][classification.deal].to_i + 1
customer[:agent_ids] = (customer[:agent_ids] | [conversation.assignee_id]).compact if conversation.assignee_id
end
def chat_message_count(conversation)
conversation.messages.chat.count
end
def channel_key(conversation)
conversation.inbox&.channel_type&.demodulize
end
# Write classifier tags onto the conversation: remove any prior M2-managed tags
# (topic:/deal: prefixes + current catalog paths), then add the fresh ones.
# Manual labels are preserved.
def apply_tags(conversation, classification, catalog_paths)
current = conversation.label_list.to_a
m2_managed = current.select do |tag|
tag.start_with?(TOPIC_TAG_PREFIX) || tag.start_with?(DEAL_TAG_PREFIX) || catalog_paths.include?(tag)
end
next_tags = current - m2_managed
next_tags += classification.products
next_tags += classification.topics.map { |topic| "#{TOPIC_TAG_PREFIX}#{topic}" }
# Persist the deal outcome on the live conversation so reports can filter by it.
next_tags << "#{DEAL_TAG_PREFIX}#{classification.deal}" if classification.deal.present?
conversation.update!(label_list: next_tags.uniq)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("[Analytics] conversation #{conversation.id} tag update failed: #{e.message}")
end
def upsert_metrics(metrics)
conv = metrics[:conversation]
conv_attrs = {
account_id: @account.id,
date: @date,
timezone: @account.reporting_timezone.presence || 'UTC',
message_count: conv[:message_count],
conversation_count: conv[:conversation_count],
resolved_count: conv[:resolved_count],
unresolved_count: conv[:unresolved_count],
top_tags: conv[:top_tags],
sale_tags: conv[:sale_tags],
deal_outcomes: conv[:deal_outcomes],
agent_breakdown: conv[:agent_breakdown].map { |id, count| { 'agent_id' => id, 'count' => count } },
team_breakdown: conv[:team_breakdown].map { |id, count| { 'team_id' => id, 'count' => count } },
channel_breakdown: conv[:channel_breakdown].map { |ch, count| { 'channel' => ch, 'count' => count } },
inbox_breakdown: conv[:inbox_breakdown].map { |id, count| { 'inbox_id' => id, 'count' => count } }
}
ConversationDailyMetric.upsert(
conv_attrs,
unique_by: %i[account_id date],
update_only: %i[message_count conversation_count resolved_count unresolved_count top_tags sale_tags
deal_outcomes agent_breakdown team_breakdown channel_breakdown inbox_breakdown timezone]
)
metrics[:customers].each do |contact_id, data|
CustomerDailyMetric.upsert(
{
account_id: @account.id,
contact_id: contact_id,
date: @date,
timezone: @account.reporting_timezone.presence || 'UTC',
message_count: data[:message_count],
conversation_count: data[:conversation_count],
resolved_count: data[:resolved_count],
unresolved_count: data[:unresolved_count],
top_tags: data[:top_tags],
deal_outcomes: data[:deal_outcomes],
agent_ids: data[:agent_ids].to_a
},
unique_by: %i[account_id contact_id date],
update_only: %i[message_count conversation_count resolved_count unresolved_count top_tags
deal_outcomes agent_ids timezone]
)
end
end
end

View File

@@ -0,0 +1,107 @@
# Deep, filterable admin drilldown over LIVE conversations (phase 2).
#
# Unlike Analytics::ReportService (which reads the immutable per-day snapshots),
# this queries the actual Conversation records scoped to a date range, so the
# filters the M2 report needs (agent / team / inbox / channel / tag / deal) can
# be applied at per-conversation granularity, with per-customer and per-agent
# cross-breakdowns and pagination.
#
# Supported filters (all optional, applied with AND):
# :since (Date) — conversations active on/after (created_at or last_activity_at)
# :until (Date) — conversations active before this date (exclusive end)
# :agent_id (Integer)
# :team_id (Integer)
# :inbox_id (Integer)
# :channel (String, e.g. "Channel::WebWidget") — matched against inbox.channel_type
# :tag (String) — matches any tag in the conversation label_list (exact)
# :deal (String, "won"|"lost"|"undecided") — matches the "deal:<value>" tag
# :page, :per_page — pagination (default 1 / 25)
#
# Admin-only; access is enforced by the calling controller/policy.
class Analytics::DrilldownService
DEFAULT_PER_PAGE = 25
# @param account [Account]
# @param filters [Hash]
def self.build(account:, filters: {})
new(account: account, filters: filters).build
end
def initialize(account:, filters: {})
@account = account
@filters = filters.symbolize_keys
end
def build
scope = filtered_scope
{
total: scope.count,
page: page,
per_page: per_page,
conversations: scope.offset((page - 1) * per_page).limit(per_page).map { |c| serialize(c) }
}
end
private
attr_reader :account, :filters
def page = (filters[:page] || 1).to_i
def per_page = (filters[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100)
def filtered_scope
scope = account.conversations
if filters[:since].present?
start = filters[:since]
scope = scope.where('created_at >= ?', start)
end
if filters[:until].present?
scope = scope.where('created_at < ?', filters[:until] + 1)
end
scope = scope.where(assignee_id: filters[:agent_id]) if filters[:agent_id].present?
scope = scope.where(team_id: filters[:team_id]) if filters[:team_id].present?
scope = scope.where(inbox_id: filters[:inbox_id]) if filters[:inbox_id].present?
if filters[:channel].present?
scope = scope.joins(:inbox).where(inboxes: { channel_type: filters[:channel] })
end
if filters[:tag].present?
tag = filters[:tag].to_s
matches = account.labels.where(title: tag).pluck(:id)
# No conversation can carry a label that doesn't exist — return none.
return scope.none if matches.empty?
scope = scope.joins(:labels).where('labels.id IN (?)', matches)
end
if filters[:deal].present?
deal_tag = "deal:#{filters[:deal]}"
matches = account.labels.where(title: deal_tag).pluck(:id)
# No conversation can carry a deal tag that doesn't exist — return none.
return scope.none if matches.empty?
scope = scope.joins(:labels).where('labels.id IN (?)', matches)
end
scope.order(:created_at)
end
def serialize(conversation)
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
assignee_id: conversation.assignee_id,
team_id: conversation.team_id,
inbox_id: conversation.inbox_id,
channel: conversation.inbox&.channel_type,
status: conversation.status,
created_at: conversation.created_at,
label_list: conversation.label_list,
message_count: conversation.messages.chat.count
}
end
end

View File

@@ -0,0 +1,200 @@
# Approval-flow delivery for the weekly persona evaluation (phase 3).
#
# Delivers the weekly LLM summary + recommendations to the admin via LINE
# (primary, using quick-reply buttons), Telegram (inline keyboard), or a webhook
# (fallback), then records the admin's decision. The full persona system prompt is
# intentionally NOT included in the delivery body — it is only sent to the webhook
# AFTER the admin approves.
#
# Delivery configuration lives on Account#custom_attributes (jsonb):
# persona_line_user_id - the admin's LINE user id (for LINE push) [primary]
# persona_telegram_chat_id - the admin's Telegram chat id (for Telegram push)
# persona_webhook_url - fallback webhook URL
# persona_webhook_secret - shared secret signed into the webhook body / header
#
# Decision keys match LINE quick-reply / webhook actions:
# 'approve' | 'reject' | 'view_full_prompt'
class Analytics::PersonaApprovalService
# @param account [Account]
# @param evaluation [Analytics::WeeklyPersonaEvaluator::Result]
# @return [Hash] { delivered:, channel:, error: }
def self.deliver(account:, evaluation:)
new(account: account, evaluation: evaluation).deliver
end
# Sends an admin approval decision to the configured webhook (webhook-only, never
# LINE). Used by the :approve path to inform the integration about the decision.
# @return [Hash] { delivered:, channel: 'webhook', error: }
def self.notify_approval(account:, evaluation:, decision:)
new(account: account, evaluation: evaluation).notify_approval(decision)
end
def initialize(account:, evaluation:)
@account = account
@evaluation = evaluation
end
def deliver
return { delivered: false, error: 'no evaluation to deliver' } if @evaluation.nil? || @evaluation.disabled?
if line_delivery_available?
deliver_via_line
elsif telegram_delivery_available?
deliver_via_telegram
elsif webhook_delivery_available?
deliver_via_webhook
else
{ delivered: false, channel: nil, error: 'no delivery channel configured (set persona_line_user_id, persona_telegram_chat_id or persona_webhook_url)' }
end
end
# Sends an admin approval decision to the configured webhook. Webhook-only (never
# LINE) — used by the :approve path so the approval is not re-sent as a LINE card.
def notify_approval(decision)
return { delivered: false, channel: 'webhook', error: 'no webhook configured' } unless webhook_delivery_available?
post_to_webhook(
type: 'persona_decision',
decision: decision
)
end
private
attr_reader :account
# -- LINE (primary) ----------------------------------------------------------
def line_delivery_available?
line_user_id.present? && account.line_channels.present?
end
def deliver_via_line
channel = account.line_channels.first
channel.client.push_message(
line_user_id,
build_line_payload
)
{ delivered: true, channel: 'line' }
rescue StandardError => e
Rails.logger.error("[PersonaApproval] LINE push failed: #{e.message}")
{ delivered: false, channel: 'line', error: e.message }
end
def build_line_payload
{
type: 'text',
text: line_text,
quickReply: {
items: [
quick_reply_item('👍 Approve', 'approve'),
quick_reply_item('Reject', 'reject'),
quick_reply_item('👁 View full prompt', 'view_full_prompt')
]
}
}
end
def quick_reply_item(label, key)
{
type: 'action',
action: {
type: 'message',
label: label,
text: "persona:#{key}"
}
}
end
def line_text
summary = @evaluation.summary.to_s
recommendations = Array(@evaluation.recommendations)
[summary, '', *recommendations.map { |r| "#{r}" }].join("\n")
end
# -- Telegram ----------------------------------------------------------------
def telegram_delivery_available?
telegram_chat_id.present? && account.telegram_channels.present?
end
def deliver_via_telegram
channel = account.telegram_channels.first
response = HTTParty.post(
"#{channel.telegram_api_url}/sendMessage",
body: {
chat_id: telegram_chat_id,
text: line_text,
reply_markup: build_telegram_keyboard
}
)
success = response.success?
Rails.logger.error("[PersonaApproval] Telegram send failed: #{response.parsed_response}") unless success
{ delivered: success, channel: 'telegram', error: success ? nil : 'telegram send failed' }
rescue StandardError => e
Rails.logger.error("[PersonaApproval] Telegram delivery failed: #{e.message}")
{ delivered: false, channel: 'telegram', error: e.message }
end
# Inline keyboard with the three decision buttons (callback_data = persona:<key>)
def build_telegram_keyboard
{
inline_keyboard: [
[
{ text: '👍 Approve', callback_data: 'persona:approve' },
{ text: 'Reject', callback_data: 'persona:reject' },
{ text: '👁 View full prompt', callback_data: 'persona:view_full_prompt' }
]
]
}.to_json
end
# -- Webhook (fallback) ------------------------------------------------------
def webhook_delivery_available?
webhook_url.present?
end
# Shared webhook POST: builds the payload from the evaluation plus extra fields.
def post_to_webhook(extra_fields)
body = {
summary: @evaluation.summary,
recommendations: Array(@evaluation.recommendations)
}.merge(extra_fields)
response = HTTParty.post(
webhook_url,
body: body.to_json,
headers: webhook_headers
)
success = response.success?
Rails.logger.error("[PersonaApproval] webhook #{response.code}") unless success
{ delivered: success, channel: 'webhook', error: success ? nil : "webhook responded #{response.code}" }
rescue StandardError => e
Rails.logger.error("[PersonaApproval] webhook failed: #{e.message}")
{ delivered: false, channel: 'webhook', error: e.message }
end
def deliver_via_webhook
post_to_webhook(
type: 'persona_evaluation',
actions: %w[approve reject view_full_prompt]
)
end
def webhook_headers
headers = { 'Content-Type' => 'application/json' }
headers['X-Persona-Signature'] = signature if webhook_secret.present?
headers
end
def signature
OpenSSL::HMAC.hexdigest('sha256', webhook_secret, @evaluation.summary.to_s)
end
# -- config ------------------------------------------------------------------
def line_user_id = account.custom_attributes['persona_line_user_id']
def telegram_chat_id = account.custom_attributes['persona_telegram_chat_id']
def webhook_url = account.custom_attributes['persona_webhook_url']
def webhook_secret = account.custom_attributes['persona_webhook_secret']
end

View File

@@ -0,0 +1,228 @@
# Imports product catalog entries for an account from pasted text, CSV/TSV, or an
# uploaded .xlsx spreadsheet (phase 3, admin-only).
#
# Accepted formats:
# - copy/paste: one product per line, columns separated by tab or '|'
# - CSV: standard RFC4180 with a header row
# - XLSX: an uploaded spreadsheet opened via the `roo` gem (file_path:)
# Columns (in order): group, subgroup, product, display, aliases
# group, product are required; subgroup/display optional; aliases separator is
# comma or semicolon.
#
# Hierarchical semantics: a row defines one product leaf. The classifier matches
# from the lowest level first and tags ancestors, so only leaf rows are stored;
# no need to also store group/subgroup as standalone rows.
#
# Upsert semantics: same (account_id, group_name, product_name) is replaced with
# the latest row (aliases/subgroup/display overwritten), keeping the catalog unique.
#
# Returns a Hash: { imported: n, updated: n, errors: [{ line, message }] }.
class Analytics::ProductCatalogImportService
COLUMNS = %w[group_name subgroup_name product_name display_name aliases].freeze
COLUMN_ALIASES = {
'group' => 'group_name', 'product' => 'product_name',
'subgroup' => 'subgroup_name', 'display' => 'display_name'
}.freeze
REQUIRED = %w[group_name product_name].freeze
ALIAS_SPLIT = /[,;]/
# @param account [Account]
# @param content [String] raw pasted text or file content (CSV/TSV/pipe)
# @return [Hash]
def self.import(account:, content:)
new(account: account, content: content).import
end
# @param account [Account]
# @param file_path [String] path to an uploaded .xlsx/.ods/.csv file (via roo)
# @param filename [String, nil] original upload filename (used to detect extension
# when the temp path has none, e.g. RackMultipart tempfile)
# @return [Hash]
def self.import_file(account:, file_path:, filename: nil)
new(account: account, content: nil, file_path: file_path, filename: filename).import
end
def initialize(account:, content: nil, file_path: nil, filename: nil)
@account = account
@content = content.to_s
@file_path = file_path
@filename = filename
end
def import
rows = (@file_path ? parse_spreadsheet : parse_rows)
return { imported: 0, updated: 0, errors: rows[:errors] } if rows[:data].empty?
result = upsert_rows(rows[:data])
result.merge(errors: rows[:errors])
end
private
attr_reader :account
# Convert an .xlsx/.xls/.ods sheet into the same { data, errors } shape as
# parse_rows by serializing each row into a pipe-delimited text line, then
# reusing the shared row parser. The first row is treated as a header when it
# looks like declared column names (same detection as text input).
def parse_spreadsheet
require 'roo'
sheet = Roo::Spreadsheet.open(@file_path, extension: extension).sheet(0)
lines = (1..sheet.last_row).filter_map do |idx|
row = (1..sheet.last_column).map { |col| sheet.cell(idx, col).to_s }
row.join('|') unless row.all?(&:blank?)
end
parse_rows_from_lines(lines)
rescue LoadError
# roo gem unavailable (should be resolved after bundle install).
Rails.logger.error('[ProductCatalogImport] roo gem not available; cannot read spreadsheet')
{ data: [], errors: [{ line: 1, message: 'spreadsheet reading is unavailable (roo gem not installed)' }] }
rescue StandardError => e
Rails.logger.error("[ProductCatalogImport] xlsx parse failed: #{e.message}")
{ data: [], errors: [{ line: 1, message: "could not read spreadsheet: #{e.message}" }] }
end
def extension
# Prefer the original upload filename (Rack tempfiles lack a useful extension);
# fall back to the path if no filename was supplied.
name = @filename.presence || @file_path.to_s
File.extname(name).delete('.').presence
end
# Shared row->attrs pipeline used by both text and spreadsheet input.
def parse_rows_from_lines(lines)
return { data: [], errors: [] } if lines.empty?
header_columns = detect_header_columns(lines)
line_offset = header_columns ? 1 : 0
keyword_mode = lines.any? { |line| keyword_line?(line) }
data = []
errors = []
lines.each_with_index do |line, idx|
raw = split_line(line)
attrs = row_to_attrs(raw, header_columns: header_columns, keyword: keyword_mode, line_no: idx + 1 + line_offset)
if attrs.is_a?(Hash)
data << attrs
else
errors << { line: idx + 1 + line_offset, message: attrs }
end
end
{ data: data, errors: errors }
end
# @return [Hash] { data: [attrs...], errors: [{ line:, message: }] }
def parse_rows
lines = @content.strip.split(/\r?\n/).reject(&:blank?)
parse_rows_from_lines(lines)
end
# If the first line looks like a declared column header (e.g. "group,product" or
# "group|subgroup|product|display|aliases", accepting group/group_name and
# product/product_name), return the normalized column names so data rows are
# mapped by name. Returns nil otherwise.
def detect_header_columns(lines)
first = split_line(lines.first).map { |v| v.to_s.strip.downcase }
normalized = first.map { |col| COLUMN_ALIASES.fetch(col, col) }
return nil unless normalized.all? { |col| COLUMNS.include?(col) }
lines.shift
normalized
end
def keyword_line?(line)
line =~ /(?:^|[\t\|\s])(group_name|group|product_name|product|subgroup_name|display_name|aliases)\s*:/
end
# Split a line by a consistent delimiter. If tabs present -> TSV; else '|' -> pipe; else comma -> CSV.
def split_line(line)
if line.include?("\t")
line.split("\t")
elsif line.include?('|')
line.split('|')
else
CSV.parse_line(line) || []
end
end
def row_to_attrs(raw, header_columns:, keyword:, line_no:)
values = raw.map(&:to_s).map(&:strip)
values = values.map(&:presence).compact
return 'empty row' if values.empty?
attrs =
if keyword
keyword_row_to_attrs(values)
elsif header_columns
header_row_to_attrs(raw, header_columns)
else
ordered_row_to_attrs(values)
end
return attrs if attrs.is_a?(String)
missing = REQUIRED.select { |col| attrs[col].blank? }
return "line #{line_no}: missing required column(s): #{missing.join(', ')}" unless missing.empty?
attrs['aliases'] = normalize_aliases(attrs['aliases'])
attrs
end
# Field form: "group: A | product: X | subgroup: S | aliases: a,b"
def keyword_row_to_attrs(values)
attrs = {}
values.each do |pair|
key, _, value = pair.partition(':')
normalized = COLUMN_ALIASES.fetch(key.strip.downcase, key.strip.downcase)
attrs[normalized] = value.strip.presence
end
attrs
end
# Named-header form: header tells us which column is which.
def header_row_to_attrs(raw, header_columns)
attrs = {}
raw.each_with_index do |value, i|
col = header_columns[i]
attrs[col] = value.presence if col
end
attrs
end
# Ordered form: "group, product" (no subgroup) or "group, subgroup, product".
def ordered_row_to_attrs(values)
if values.length == 2
{ 'group_name' => values[0], 'product_name' => values[1] }
else
# group, subgroup, product, [display], [aliases]
{ 'group_name' => values[0], 'subgroup_name' => values[1], 'product_name' => values[2],
'display_name' => values[3], 'aliases' => values[4] }
end
end
def normalize_aliases(value)
value.to_s.split(ALIAS_SPLIT).map(&:strip).reject(&:blank?)
end
def upsert_rows(rows)
imported = 0
updated = 0
rows.each do |attrs|
existing = @account.product_catalog_entries.find_by(
group_name: attrs['group_name'], product_name: attrs['product_name']
)
if existing
existing.update!(
subgroup_name: attrs['subgroup_name'],
display_name: attrs['display_name'],
aliases: attrs['aliases']
)
updated += 1
else
@account.product_catalog_entries.create!(attrs.merge(account_id: @account.id))
imported += 1
end
end
{ imported: imported, updated: updated }
end
end

View File

@@ -0,0 +1,113 @@
# Admin analytics summary/rollup over the immutable per-day metrics (phase 2).
#
# Reads conversation_daily_metrics + customer_daily_metrics (written by the
# Analytics::AccountDailyProcessor batch) and returns:
# - summary : totals over the date range
# - timeseries : the same totals bucketed per day (for charting)
# - customers : per-customer totals (from customer_daily_metrics)
# - agents : per-agent conversation counts (from agent_breakdown)
#
# This is the snapshot/rollup surface. Deep per-conversation filtering
# (agent/team/inbox/channel/tag/deal) lives in Analytics::DrilldownService, which
# reads live conversations. Admin-only; access is enforced by the controller/policy.
class Analytics::ReportService
# @param account [Account]
# @param since [Date]
# @param until_date [Date]
def self.build(account:, since: nil, until_date: nil)
new(account: account, since: since, until_date: until_date).build
end
def initialize(account:, since: nil, until_date: nil)
@account = account
@since = since
@until_date = until_date
end
def build
{
summary: summary,
timeseries: timeseries,
customers: customers,
agents: agents
}
end
private
attr_reader :account
def since
@since || ConversationDailyMetric.where(account_id: account.id).minimum(:date) || Date.today
end
def until_date
@until_date || Date.today
end
def date_range = (since..until_date)
def conversation_rows
@conversation_rows ||= ConversationDailyMetric
.where(account_id: account.id)
.where(date: date_range)
.order(:date)
end
def customer_rows
@customer_rows ||= CustomerDailyMetric
.where(account_id: account.id)
.where(date: date_range)
.order(:date, :contact_id)
end
# Aggregate a set of rows into normalized totals.
def totals(rows)
{
conversation_count: rows.sum { |r| r.conversation_count },
message_count: rows.sum { |r| r.message_count },
resolved_count: rows.sum { |r| r.resolved_count },
unresolved_count: rows.sum { |r| r.unresolved_count },
deal_outcomes: merge_deal_outcomes(rows),
top_tags: merge_top_tags(rows)
}
end
def summary = totals(conversation_rows)
def timeseries
conversation_rows.group_by(&:date).map { |date, rows| totals(rows).merge(date: date) }
end
def customers
customer_rows.group_by(&:contact_id).map do |contact_id, rows|
totals(rows).merge(contact_id: contact_id)
end
end
def agents
per_agent = Hash.new { |h, k| h[k] = 0 }
conversation_rows.each do |row|
Array(row.agent_breakdown).each { |entry| per_agent[entry['agent_id']] += entry['count'].to_i }
end
per_agent.map { |agent_id, count| { agent_id: agent_id, conversation_count: count } }
.sort_by { |entry| -entry[:conversation_count] }
end
# conversation rows store deal_outcomes nested under 'totals'; customer rows flat.
# Normalize both into a flat { deal => count } mapping.
def merge_deal_outcomes(rows)
rows.each_with_object({}) do |row, acc|
row.deal_outcomes.each do |key, value|
data = value.is_a?(Hash) ? value : { key => value }
data.each { |deal, count| acc[deal] = (acc[deal] || 0) + count.to_i }
end
end
end
def merge_top_tags(rows)
rows.each_with_object(Hash.new(0)) do |row, acc|
Array(row.top_tags).each { |tag| acc[tag] += 1 }
end.sort_by { |_tag, count| -count }.to_h
end
end

View File

@@ -0,0 +1,123 @@
# Weekly persona evaluation for the self-improving chatbot (phase 3).
#
# Summarizes the last 7 days of immutable daily metrics (Analytics::ReportService /
# ConversationDailyMetric + CustomerDailyMetric via Analytics::ReportService) and asks the
# LLM (via the Llm::Resolver cascade) to recommend persona/system-prompt improvements.
#
# Output is the SUMMARY ONLY (human-readable recommendation) — the full system prompt is
# intentionally NOT produced/revealed here; it is gated behind the admin approval flow.
#
# Like Llm::AnalyticsClassifier, this is a pure evaluator: it CLASSIFIES/SUMMARIZES and
# returns a Result; persisting the recommendation is the caller's responsibility
# (the weekly job / approval flow). Fail-closed: no LLM credential -> { disabled: true },
# never sends conversation content when disabled.
module Analytics::WeeklyPersonaEvaluator
SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
summary: {
type: 'string',
description: 'A concise human-readable summary of the week: top topics, sales wins/losses, and any notable trends.'
},
recommendations: {
type: 'array',
items: { type: 'string' },
description: 'Concrete, actionable recommendations to improve the chatbot persona/behavior next week.'
}
},
required: %w[summary recommendations]
}.freeze
Result = Struct.new(:summary, :recommendations, :disabled, :error, keyword_init: true) do
def disabled?
disabled == true
end
def success?
error.nil?
end
end
WINDOW_DAYS = 7
module_function
# @param account [Account]
# @param report [Hash] output of Analytics::ReportService.build (or built here if nil)
# @return [Analytics::WeeklyPersonaEvaluator::Result]
def evaluate(account:, report: nil)
credential = Llm::Resolver.resolve(account)
return disabled_result if credential.nil?
report ||= Analytics::ReportService.build(account: account, since: WINDOW_DAYS.days.ago.to_date, until_date: Date.today)
response = call_llm(credential, build_prompt(report))
build_result(response)
rescue StandardError => e
Rails.logger.error("[WeeklyPersonaEvaluator] account=#{account&.id} #{e.class}: #{e.message}")
Result.new(error: e.message)
end
# -- result helpers ---------------------------------------------------------
def disabled_result
Result.new(summary: nil, recommendations: [], disabled: true)
end
def build_result(response)
return Result.new(error: response[:error] || 'evaluation failed') if response[:error]
parsed = JSON.parse(sanitize_json(response[:content]))
Result.new(
summary: parsed['summary'],
recommendations: Array(parsed['recommendations']),
disabled: false
)
rescue JSON::ParserError, TypeError
Result.new(error: 'LLM returned an unparsable evaluation')
end
# -- LLM call ---------------------------------------------------------------
def call_llm(credential, prompt)
Llm::Config.with_api_key(credential[:api_key], api_base: credential[:api_base]) do |context|
chat = context.chat(model: MODEL).with_schema(SCHEMA)
chat.with_instructions(SYSTEM_PROMPT)
{ content: chat.ask(prompt).content }
end
rescue StandardError => e
Rails.logger.error("[WeeklyPersonaEvaluator] LLM call failed #{e.class}: #{e.message}")
{ error: e.message }
end
MODEL = Llm::Config::DEFAULT_MODEL
# -- prompt construction ----------------------------------------------------
def build_prompt(report)
summary = report[:summary].to_h
[
'Here is the past week of customer-service analytics for the account:',
'',
"Conversations: #{summary[:conversation_count]}",
"Messages: #{summary[:message_count]}",
"Resolved: #{summary[:resolved_count]}",
"Unresolved: #{summary[:unresolved_count]}",
"Deal outcomes: #{summary[:deal_outcomes].inspect}",
"Top tags: #{summary[:top_tags].inspect}",
'',
'Based on this, recommend persona / behavior improvements for the chatbot.'
].join("\n")
end
def sanitize_json(content)
content.to_s.gsub('```json', '').gsub('```', '').strip
end
SYSTEM_PROMPT = <<~PROMPT.freeze
You are a customer-service improvement analyst. Given a week of aggregate metrics,
write a concise summary and 2-5 concrete, actionable recommendations to improve the
chatbot's persona and behavior. Keep recommendations specific and grounded in the data.
Return only the JSON object described by the schema no extra text.
PROMPT
end