WeLe Agentic AI: LangGraph multi-agent CRM assistant with voice
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// ============================================
|
||||
// Shared query helpers for the CRM read tools.
|
||||
// ============================================
|
||||
import { z } from 'zod';
|
||||
import { EnrollmentLog } from '../../data/models/index.js';
|
||||
|
||||
/** Payment status that counts as a real enrolment. */
|
||||
export const PAID = 'SUCCESS';
|
||||
|
||||
/**
|
||||
* Phone numbers that have actually paid, in every format the CRM stores.
|
||||
*
|
||||
* Lead.enrolled is false on every lead record — nothing in the CRM sets it —
|
||||
* so enrolment truth lives in `enrollmentlogs`. Those hold bare 10-digit
|
||||
* mobiles while leads are stored 91-prefixed, so a naive join matches nothing.
|
||||
* The paid set is small (tens of rows), so materialising it and matching with
|
||||
* $in beats a $lookup doing string surgery across 4.4k leads.
|
||||
*
|
||||
* Cached briefly: several tools in one turn otherwise repeat the same scan.
|
||||
*/
|
||||
let _paidCache = { at: 0, value: null };
|
||||
export async function paidPhoneVariants(ttlMs = 60_000) {
|
||||
if (_paidCache.value && Date.now() - _paidCache.at < ttlMs) return _paidCache.value;
|
||||
const rows = await EnrollmentLog.find({ paymentStatus: PAID }, { primaryMobile: 1 }).lean();
|
||||
const out = new Set();
|
||||
for (const r of rows) {
|
||||
const d = String(r.primaryMobile || '').replace(/\D/g, '');
|
||||
if (d.length < 10) continue;
|
||||
const ten = d.slice(-10);
|
||||
out.add(ten);
|
||||
out.add('91' + ten);
|
||||
}
|
||||
_paidCache = { at: Date.now(), value: [...out] };
|
||||
return _paidCache.value;
|
||||
}
|
||||
|
||||
/** Normalise to the 12-digit Indian format the CRM stores (91XXXXXXXXXX). */
|
||||
export function normalizePhone(raw = '') {
|
||||
const digits = String(raw).replace(/\D/g, '');
|
||||
if (digits.length === 10) return '91' + digits;
|
||||
if (digits.length === 12 && digits.startsWith('91')) return digits;
|
||||
if (digits.length === 13 && digits.startsWith('091')) return digits.slice(1);
|
||||
return digits;
|
||||
}
|
||||
|
||||
/** Match either stored form — some leads predate country-code normalisation. */
|
||||
export function phoneVariants(raw) {
|
||||
const n = normalizePhone(raw);
|
||||
const short = n.startsWith('91') ? n.slice(2) : n;
|
||||
return [...new Set([n, short, raw])].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative date windows. The model is far more reliable picking a named window
|
||||
* than computing ISO timestamps, and this keeps "this month" meaning the same
|
||||
* thing in every tool.
|
||||
*/
|
||||
export const DATE_RANGES = [
|
||||
'today', 'yesterday', 'last_7_days', 'last_30_days', 'last_90_days',
|
||||
'this_week', 'this_month', 'last_month', 'this_quarter', 'this_year', 'all_time',
|
||||
];
|
||||
|
||||
export function resolveRange(range = 'last_30_days', now = new Date()) {
|
||||
const d = (x) => new Date(x);
|
||||
const startOfDay = (x) => { const y = d(x); y.setHours(0, 0, 0, 0); return y; };
|
||||
const end = new Date(now);
|
||||
let start;
|
||||
|
||||
switch (range) {
|
||||
case 'today': start = startOfDay(now); break;
|
||||
case 'yesterday': {
|
||||
start = startOfDay(now); start.setDate(start.getDate() - 1);
|
||||
const e = startOfDay(now); return { start, end: e, label: 'yesterday' };
|
||||
}
|
||||
case 'last_7_days': start = startOfDay(now); start.setDate(start.getDate() - 7); break;
|
||||
case 'last_30_days': start = startOfDay(now); start.setDate(start.getDate() - 30); break;
|
||||
case 'last_90_days': start = startOfDay(now); start.setDate(start.getDate() - 90); break;
|
||||
case 'this_week': {
|
||||
start = startOfDay(now);
|
||||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7)); // Monday
|
||||
break;
|
||||
}
|
||||
case 'this_month': start = new Date(now.getFullYear(), now.getMonth(), 1); break;
|
||||
case 'last_month': {
|
||||
start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
return { start, end: new Date(now.getFullYear(), now.getMonth(), 1), label: 'last month' };
|
||||
}
|
||||
case 'this_quarter': start = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1); break;
|
||||
case 'this_year': start = new Date(now.getFullYear(), 0, 1); break;
|
||||
case 'all_time': return { start: new Date(0), end, label: 'all time' };
|
||||
default: start = startOfDay(now); start.setDate(start.getDate() - 30);
|
||||
}
|
||||
return { start, end, label: range.replace(/_/g, ' ') };
|
||||
}
|
||||
|
||||
export const dateRangeSchema = z.enum(DATE_RANGES).default('last_30_days')
|
||||
.describe('Relative time window for the query.');
|
||||
|
||||
/** Fields safe to project to the model — excludes tracking/PII noise. */
|
||||
export const LEAD_SUMMARY_FIELDS = {
|
||||
phone_number: 1, name: 1, wa_name: 1, email: 1, lead_score: 1, lead_tag: 1,
|
||||
current_stage: 1, funnel_stage: 1, segment: 1, source: 1, interested_course: 1,
|
||||
interested_courses: 1, qualification: 1, assigned_to: 1, enrolled: 1,
|
||||
enrollment_date: 1, follow_up_date: 1, last_interaction: 1, first_interaction: 1,
|
||||
total_messages: 1, tags: 1, createdAt: 1, needs_human: 1, city: 1,
|
||||
};
|
||||
|
||||
/** Trim a Mongo doc down for the model: drop nulls and empty strings. */
|
||||
export function compact(doc) {
|
||||
if (!doc || typeof doc !== 'object') return doc;
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(doc)) {
|
||||
if (v == null || v === '' || (Array.isArray(v) && v.length === 0)) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a person reference — a phone number OR a name — to phone numbers.
|
||||
*
|
||||
* Conversations are keyed by phone and their `participant_name` is empty on
|
||||
* ~99.8% of records (4,004 of 4,014 in the live database), so a name can only
|
||||
* be resolved by going through the Lead collection and joining on phone. Any
|
||||
* conversation lookup that searched `participant_name` directly reported "no
|
||||
* chat found" for people who plainly had one.
|
||||
*
|
||||
* @returns {{phones: string[], matches: Array<{name:string, phone:string}>, ambiguous: boolean}}
|
||||
*/
|
||||
export async function resolvePerson(query) {
|
||||
const { Lead } = await import('../../data/models/index.js');
|
||||
const raw = String(query || '').trim();
|
||||
if (!raw) return { phones: [], matches: [], ambiguous: false };
|
||||
|
||||
// Enough digits to be a phone number → use it directly.
|
||||
if ((raw.match(/\d/g) || []).length >= 8) {
|
||||
return { phones: phoneVariants(raw), matches: [], ambiguous: false };
|
||||
}
|
||||
|
||||
// Otherwise treat it as a name. Escape regex metacharacters so a name like
|
||||
// "R. Kumar (Dev)" cannot blow up or match unintended records.
|
||||
const safe = [...raw].map((ch) => ('\\^$.|?*+()[]{}'.includes(ch) ? '\\' + ch : ch)).join('');
|
||||
// "md salim" should still match "Md. Salim" — join words with a wildcard.
|
||||
const rx = new RegExp(safe.trim().split(/\s+/).join('.*'), 'i');
|
||||
|
||||
const leads = await Lead.find({ $or: [{ name: rx }, { wa_name: rx }] })
|
||||
.select('name wa_name phone_number')
|
||||
.limit(10)
|
||||
.lean();
|
||||
|
||||
const matches = leads.map((l) => ({ name: l.name || l.wa_name || '(unnamed)', phone: l.phone_number }));
|
||||
const phones = [...new Set(leads.flatMap((l) => phoneVariants(l.phone_number)))];
|
||||
return { phones, matches, ambiguous: matches.length > 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversations rarely carry a usable `participant_name`, so attach the lead's
|
||||
* name by phone. Without this every inbox listing reads as a wall of numbers.
|
||||
*/
|
||||
export async function attachLeadNames(rows, phoneField = 'phone_number') {
|
||||
if (!rows?.length) return rows;
|
||||
const { Lead } = await import('../../data/models/index.js');
|
||||
|
||||
const phones = [...new Set(rows.flatMap((r) => phoneVariants(r[phoneField])))];
|
||||
const leads = await Lead.find({ phone_number: { $in: phones } })
|
||||
.select('name wa_name phone_number current_stage lead_tag')
|
||||
.lean();
|
||||
|
||||
const byPhone = new Map();
|
||||
for (const l of leads) {
|
||||
for (const v of phoneVariants(l.phone_number)) {
|
||||
byPhone.set(v, l);
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((r) => {
|
||||
const lead = byPhone.get(normalizePhone(r[phoneField])) || byPhone.get(r[phoneField]);
|
||||
return {
|
||||
...r,
|
||||
participant_name: r.participant_name || lead?.name || lead?.wa_name || '',
|
||||
lead_stage: lead?.current_stage,
|
||||
lead_tag: lead?.lead_tag,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user