diff --git a/scripts/t-dashboard-gap.mjs b/scripts/t-dashboard-gap.mjs new file mode 100644 index 0000000..9b83fe0 --- /dev/null +++ b/scripts/t-dashboard-gap.mjs @@ -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); diff --git a/scripts/t-dashboard-probe.mjs b/scripts/t-dashboard-probe.mjs new file mode 100644 index 0000000..d5ad11c --- /dev/null +++ b/scripts/t-dashboard-probe.mjs @@ -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); diff --git a/scripts/t-dashboard-tool.mjs b/scripts/t-dashboard-tool.mjs new file mode 100644 index 0000000..3621281 --- /dev/null +++ b/scripts/t-dashboard-tool.mjs @@ -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); diff --git a/src/agents/registry.js b/src/agents/registry.js index c7c6ca4..8dab8da 100644 --- a/src/agents/registry.js +++ b/src/agents/registry.js @@ -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.', }, { diff --git a/src/tools/crm/dashboard.tools.js b/src/tools/crm/dashboard.tools.js new file mode 100644 index 0000000..ee2a58e --- /dev/null +++ b/src/tools/crm/dashboard.tools.js @@ -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];