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:
129
app/controllers/api/v2/accounts/analytics_reports_controller.rb
Normal file
129
app/controllers/api/v2/accounts/analytics_reports_controller.rb
Normal 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
|
||||
@@ -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
|
||||
18
app/javascript/dashboard/api/analyticsReports.js
Normal file
18
app/javascript/dashboard/api/analyticsReports.js
Normal 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();
|
||||
26
app/javascript/dashboard/api/productCatalog.js
Normal file
26
app/javascript/dashboard/api/productCatalog.js
Normal 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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
35
app/jobs/analytics/daily_metrics_job.rb
Normal file
35
app/jobs/analytics/daily_metrics_job.rb
Normal 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
|
||||
23
app/jobs/analytics/weekly_persona_evaluation_job.rb
Normal file
23
app/jobs/analytics/weekly_persona_evaluation_job.rb
Normal 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
|
||||
218
app/services/analytics/account_daily_processor.rb
Normal file
218
app/services/analytics/account_daily_processor.rb
Normal 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
|
||||
107
app/services/analytics/drilldown_service.rb
Normal file
107
app/services/analytics/drilldown_service.rb
Normal 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
|
||||
200
app/services/analytics/persona_approval_service.rb
Normal file
200
app/services/analytics/persona_approval_service.rb
Normal 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
|
||||
228
app/services/analytics/product_catalog_import_service.rb
Normal file
228
app/services/analytics/product_catalog_import_service.rb
Normal 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
|
||||
113
app/services/analytics/report_service.rb
Normal file
113
app/services/analytics/report_service.rb
Normal 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
|
||||
123
app/services/analytics/weekly_persona_evaluator.rb
Normal file
123
app/services/analytics/weekly_persona_evaluator.rb
Normal 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
|
||||
Reference in New Issue
Block a user