removed the voice agent

This commit is contained in:
2026-09-02 09:23:53 +05:30
parent 8ae5e7ebb9
commit e94a483cfa
5 changed files with 327 additions and 9 deletions
+69
View File
@@ -0,0 +1,69 @@
/* How far do the agent's numbers drift from the CRM dashboard?
The dashboard resolves a lead's stage by looking up `leadstages` and taking
the furthest stage across pipelines; the CRM's own code comments say
`Lead.current_stage` is "stale and never written". The agent's lead tools
read that stale field, so this measures the resulting gap.
*/
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import { connectMongo } from '../src/data/mongo.js';
import { Lead } from '../src/data/models/index.js';
dotenv.config();
const CRM = process.env.CRM_PUBLIC_URL || 'https://crm.wele.in';
const token = jwt.sign({ id: '6a00cefe524bebd27037a968' }, process.env.CRM_JWT_SECRET, { expiresIn: '15m' });
await connectMongo();
// ── what the AGENT currently reports (stale field) ──────────────────────────
const agentStages = await Lead.aggregate([
{ $group: { _id: '$current_stage', n: { $sum: 1 } } },
{ $sort: { n: -1 } },
]);
const agent = Object.fromEntries(agentStages.map((r) => [r._id || 'unset', r.n]));
const agentTotal = agentStages.reduce((s, r) => s + r.n, 0);
// ── what the DASHBOARD reports (leadstages lookup, workshop excluded) ───────
// parseRange takes from/to as YYYY-MM-DD in IST and DEFAULTS TO TODAY — a
// missing range silently returns one day, which is the first trap here.
const today = new Date(Date.now() + 5.5 * 3600e3).toISOString().slice(0, 10);
const res = await fetch(`${CRM}/api/dashboard/leads?from=2020-01-01&to=${today}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(60000),
});
if (!res.ok) {
console.log(`dashboard call failed: ${res.status} ${(await res.text()).slice(0, 200)}`);
process.exit(1);
}
const dash = await res.json();
console.log('=== DASHBOARD response shape ===');
console.log(' ', Object.keys(dash).join(', '));
const d = dash.data ?? dash;
console.log(' data keys:', Object.keys(d).join(', '));
console.log('\n=== TOTAL LEADS ===');
console.log(` agent (Lead collection) ${agentTotal}`);
console.log(` dashboard totalLeads ${d.totalLeads ?? d.total ?? '(not found)'}`);
console.log('\n=== STAGE DISTRIBUTION ===');
console.log(` ${'stage'.padEnd(24)} ${'agent'.padStart(7)} ${'dashboard'.padStart(10)} gap`);
console.log(` dashboard cards.total_leads ${d.cards?.total_leads}`);
console.log(` dashboard range ${d.range?.from} → ${d.range?.to}`);
const funnel = d.funnel || d.stages || {};
const dashStages = Array.isArray(funnel)
? Object.fromEntries(funnel.map((f) => [f._id || f.stage || f.name, f.count ?? f.total]))
: funnel;
const keys = [...new Set([...Object.keys(agent), ...Object.keys(dashStages)])];
for (const k of keys) {
const a = agent[k] ?? 0;
const b = dashStages[k] ?? 0;
const gap = b - a;
console.log(` ${String(k).padEnd(24)} ${String(a).padStart(7)} ${String(b).padStart(10)} ${gap === 0 ? '—' : (gap > 0 ? '+' : '') + gap}`);
}
console.log('\nRaw dashboard payload (first 900 chars) for field mapping:');
console.log(JSON.stringify(d).slice(0, 900));
process.exit(0);
+50
View File
@@ -0,0 +1,50 @@
/* Shape of every CRM dashboard endpoint, so the agent tools can be written
against what they actually return rather than against a guess. */
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
dotenv.config();
const CRM = process.env.CRM_PUBLIC_URL || 'https://crm.wele.in';
const token = jwt.sign({ id: '6a00cefe524bebd27037a968' }, process.env.CRM_JWT_SECRET, { expiresIn: '15m' });
const today = new Date(Date.now() + 5.5 * 3600e3).toISOString().slice(0, 10);
const from = '2026-08-01';
const ENDPOINTS = [
'dashboard/leads',
'dashboard/marketing',
'dashboard/retargeting',
'dashboard/budget',
'dashboard/courses',
'dashboard/analytics',
'dashboard/meta-analytics',
];
const shape = (v, depth = 0) => {
if (Array.isArray(v)) return v.length ? `[${v.length} × ${shape(v[0], depth + 1)}]` : '[]';
if (v && typeof v === 'object') {
const keys = Object.keys(v);
if (depth >= 1) return `{${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}}`;
return `{\n${keys.map((k) => ` ${k}: ${shape(v[k], depth + 1)}`).join('\n')}\n }`;
}
return typeof v === 'string' ? `"${String(v).slice(0, 24)}"` : String(v);
};
for (const ep of ENDPOINTS) {
const url = `${CRM}/api/${ep}?from=${from}&to=${today}`;
try {
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(90000) });
const body = await res.text();
if (!res.ok) { console.log(`\n${ep} HTTP ${res.status} ${body.slice(0, 120)}`); continue; }
const j = JSON.parse(body);
const d = j.data ?? j;
console.log(`\n${'─'.repeat(70)}\n${ep} (${(body.length / 1024).toFixed(1)} kB)`);
console.log(` ${shape(d)}`);
// Headline numbers are what the assistant will most often be asked for.
if (d.cards) console.log(` cards → ${JSON.stringify(d.cards)}`);
if (d.totals) console.log(` totals → ${JSON.stringify(d.totals).slice(0, 300)}`);
if (d.summary) console.log(` summary → ${JSON.stringify(d.summary).slice(0, 300)}`);
} catch (e) {
console.log(`\n${ep} ERROR ${e.message.slice(0, 90)}`);
}
}
process.exit(0);
+29
View File
@@ -0,0 +1,29 @@
/* Does crm_dashboard return the dashboard's own numbers, across every section? */
import dotenv from 'dotenv';
dotenv.config();
process.env.CRM_API_BASE = process.env.CRM_PUBLIC_URL || 'https://crm.wele.in';
const { crmDashboard } = await import('../src/tools/crm/dashboard.tools.js');
const cfg = { configurable: { user: { id: '6a00cefe524bebd27037a968', name: 'Test', role: 'admin' }, runtime: { toolCallCount: 0 } } };
const CASES = [
['leads', 'all_time'], ['leads', 'this_month'], ['marketing', 'this_month'],
['budget', 'this_month'], ['courses', 'this_month'], ['analytics', 'last_7_days'],
['retargeting', 'this_month'], ['meta_analytics', 'this_month'],
];
let pass = 0;
for (const [section, range] of CASES) {
try {
const raw = await crmDashboard.invoke({ section, range }, cfg);
const r = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (r.ok === false) { console.log(`FAIL ${section}/${range}: ${r.error}`); continue; }
const d = r.data ?? r;
const head = d.cards ? JSON.stringify(d.cards) : (d.overview ? JSON.stringify(d.overview).slice(0,150) : Object.keys(d).slice(0,6).join(','));
console.log(`PASS ${section.padEnd(15)} ${range.padEnd(12)} ${d.period?.from}→${d.period?.to}`);
console.log(` ${head.slice(0, 190)}`);
pass++;
} catch (e) { console.log(`FAIL ${section}/${range}: ${e.message.slice(0,120)}`); }
}
console.log(`\n${pass}/${CASES.length} sections working`);
process.exit(0);
+19 -9
View File
@@ -14,6 +14,7 @@ import {
scheduleTools, callTools, courseTools, campaignTools, peopleTools,
} from '../tools/crm/operations.tools.js';
import { attributionTools } from '../tools/crm/attribution.tools.js';
import { dashboardTools } from '../tools/crm/dashboard.tools.js';
/**
* @typedef {object} AgentDef
@@ -35,8 +36,11 @@ export const AGENTS = [
tools: leadTools,
prompt:
'You own the CRM lead pipeline. Resolve people by phone number — it is the join key across every collection.\n'
+ 'Prefer a single well-filtered search over several broad ones. When a question is about counts or '
+ 'distribution, use lead_funnel_breakdown rather than listing records and counting them yourself.\n'
+ 'You find and work INDIVIDUAL leads. You are NOT the source for counts: the stage field on a lead '
+ 'record is stale and never written, so any total computed from it disagrees with the dashboard the '
+ 'user is looking at. Send every "how many" or distribution question to the Analytics agent, which '
+ 'reads the dashboard directly.\n'
+ 'Prefer a single well-filtered search over several broad ones.\n'
+ 'Stage vocabulary: new_lead → contacted → qualified → demo → payment → converted, plus lost. '
+ 'Temperature is cold/warm/hot.',
},
@@ -46,14 +50,20 @@ export const AGENTS = [
purpose:
'Cross-cutting reporting and business questions: overall performance, conversion funnels, team leaderboards, '
+ 'source and campaign effectiveness, enrolments, revenue and activity volumes.',
tools: analyticsTools,
tools: [...dashboardTools, ...analyticsTools],
prompt:
'You own reporting across the whole CRM. For any broad question ("how are we doing", "summarise the month") '
+ 'call business_snapshot FIRST — it answers most of it in one hop — then drill in only where needed.\n'
+ 'Critical data fact: the Lead.enrolled flag is unset on every lead record. Enrolments and revenue come from '
+ 'the payment records surfaced by enrollment_report and the enrolment figures in business_snapshot. '
+ 'Never claim zero conversions on the basis of a lead flag.\n'
+ 'Always report the period you measured. When a figure looks surprising, say so and name the likely cause '
'You own reporting across the whole CRM.\n'
+ 'USE crm_dashboard FOR EVERY count, total, rate and trend. It calls the same endpoints the CRM '
+ 'dashboard renders, so your numbers match what the user is looking at on screen. It is also the only '
+ 'route to Google Analytics, Meta ad spend, budget and course performance — none of that is in the '
+ 'database, so no other tool can answer those at all.\n'
+ 'Never compute stage counts from lead records. The stage field on a lead is stale and never written; '
+ 'the real stage lives in the stage pipelines and only crm_dashboard resolves it. Reading the field '
+ 'directly reported 3,767 leads in new_lead when the true figure was 306, and 4 converted when it was 64.\n'
+ 'The Lead.enrolled flag is likewise unset on every record — never read it as a conversion signal. '
+ 'Enrolments and revenue come from enrollment_report.\n'
+ 'For a broad question ("how are we doing"), start with crm_dashboard section=leads, then drill in.\n'
+ 'Always state the period you measured. When a figure looks surprising, say so and name the likely cause '
+ 'rather than presenting it flatly.',
},
{
+160
View File
@@ -0,0 +1,160 @@
// ============================================
// Dashboard tools — the authoritative source for counts and metrics.
//
// WHY THIS EXISTS
//
// The agent used to answer count questions by aggregating the `leads`
// collection directly on `Lead.current_stage`. The CRM's own code says of that
// field: "Lead.current_stage is stale and never written". A lead's real stage
// lives in `leadstages`, resolved as the FURTHEST stage across its pipelines,
// with workshop-funnel leads excluded from the Leads view. Measured against the
// live dashboard, the drift was not marginal:
//
// stage agent said dashboard
// new_lead 3,767 306
// contacted 500 1,506
// qualified 59 783
// converted 4 64
// lost 282 1,209
//
// Every business conclusion drawn from those numbers was wrong.
//
// The fix is deliberately NOT to reimplement the stage resolution here. The
// CRM's own comments record that duplicating the rule is exactly how a card's
// number drifts from the list it opens. So these tools call the CRM's dashboard
// endpoints — the same ones the UI renders — and the numbers agree by
// construction rather than by careful copying.
//
// This also brings Google Analytics, Meta ad spend and budget into reach; none
// of it is in Mongo, so no direct-read tool could ever have answered those.
// ============================================
import { z } from 'zod';
import { defineTool, RISK } from '../defineTool.js';
import crmApi from '../http/crmApi.js';
const AGENT = 'analytics';
/** What each section answers, for the tool description and the error text. */
const SECTIONS = {
leads: {
path: '/api/dashboard/leads',
about: 'lead counts, the pipeline funnel, sources, owner leaderboard, daily trend, recent leads',
},
marketing: {
path: '/api/dashboard/marketing',
about: 'Meta ad performance — spend, impressions, clicks, CTR, cost per lead, and per-campaign results',
},
budget: {
path: '/api/dashboard/budget',
about: 'ad budget vs actual spend, utilisation, campaign budgets, underperforming ads, account spend cap',
},
courses: {
path: '/api/dashboard/courses',
about: 'per-course lead volume, hot/warm split, conversions and conversion rate',
},
retargeting: {
path: '/api/dashboard/retargeting',
about: 'retargetable segments, how long leads have been silent, and the candidate pool',
},
analytics: {
path: '/api/dashboard/analytics',
about: 'Google Analytics — active users, sessions, page views, bounce/engagement, channels, top pages, devices, geography',
},
meta_analytics: {
path: '/api/dashboard/meta-analytics',
about: 'Meta platform analytics — reach, engagement and demographics from the ad account',
},
};
// The CRM works in IST and its parseRange defaults a MISSING range to *today*.
// A question about "this month" that forgets the dates silently returns one
// day's numbers, which looks like a catastrophic drop rather than a bug.
const IST_OFFSET_MS = 5.5 * 3600_000;
const istToday = () => new Date(Date.now() + IST_OFFSET_MS).toISOString().slice(0, 10);
function istShift(days) {
return new Date(Date.now() + IST_OFFSET_MS - days * 86_400_000).toISOString().slice(0, 10);
}
/** Named windows → the from/to pair the CRM expects. */
function resolveRange(range, from, to) {
if (from && to) return { from, to };
const today = istToday();
switch (range) {
case 'today': return { from: today, to: today };
case 'yesterday': return { from: istShift(1), to: istShift(1) };
case 'last_7_days': return { from: istShift(6), to: today };
case 'last_30_days': return { from: istShift(29), to: today };
case 'last_90_days': return { from: istShift(89), to: today };
case 'this_month': return { from: `${today.slice(0, 7)}-01`, to: today };
case 'last_month': {
const d = new Date(Date.now() + IST_OFFSET_MS);
const first = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1));
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 0));
return { from: first.toISOString().slice(0, 10), to: last.toISOString().slice(0, 10) };
}
case 'this_year': return { from: `${today.slice(0, 4)}-01-01`, to: today };
case 'all_time': return { from: '2020-01-01', to: today };
default: return { from: istShift(29), to: today };
}
}
/** Long arrays blow the context budget; headline figures matter far more. */
function trim(data) {
if (!data || typeof data !== 'object') return data;
const out = {};
for (const [k, v] of Object.entries(data)) {
if (Array.isArray(v) && v.length > 25) {
out[k] = v.slice(0, 25);
out[`${k}_note`] = `showing 25 of ${v.length}`;
} else if (k === 'heatmap' || k === 'hourlyHeatmap') {
out[k] = '(hourly heatmap omitted — ask for it specifically if needed)';
} else {
out[k] = v;
}
}
return out;
}
export const crmDashboard = defineTool({
name: 'crm_dashboard',
agent: AGENT,
permission: 'reports:read',
risk: RISK.READ,
description:
'THE source of truth for any count, total, rate or metric. Returns exactly what the CRM dashboard '
+ 'shows, because it calls the same endpoints the dashboard itself renders.\n'
+ 'Use this FIRST for any "how many", "what is the total", "what is the rate" or trend question, and '
+ 'for anything about ad spend, campaigns, courses or website traffic.\n'
+ 'Sections:\n'
+ Object.entries(SECTIONS).map(([k, v]) => ` • ${k} — ${v.about}`).join('\n'),
schema: z.object({
section: z.enum(Object.keys(SECTIONS)).describe('Which dashboard to read.'),
range: z.enum([
'today', 'yesterday', 'last_7_days', 'last_30_days', 'last_90_days',
'this_month', 'last_month', 'this_year', 'all_time',
]).default('last_30_days').describe('Named window. Ignored when from/to are given.'),
from: z.string().optional().describe('Start date YYYY-MM-DD (IST). Use with `to` for a custom window.'),
to: z.string().optional().describe('End date YYYY-MM-DD (IST).'),
}),
handler: async (a, ctx) => {
const section = SECTIONS[a.section];
const { from, to } = resolveRange(a.range, a.from, a.to);
const res = await crmApi.get(section.path, {
user: ctx.user,
query: { from, to },
timeoutMs: 60000, // GA and Meta calls are slower than a Mongo read
});
const data = res?.data ?? res;
return {
section: a.section,
period: { from, to },
...trim(data),
source: 'CRM dashboard — identical to what the UI displays',
};
},
});
export const dashboardTools = [crmDashboard];