removed the voice agent
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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.',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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];
|
||||
Reference in New Issue
Block a user