fix: share the dashboard assistant and count leads from data
The hero ask box now uses the same confirm-before-create path as the assistant, count questions use real filters, and lead mobile, Jalali follow-ups, and rial labels stay consistent. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,22 +12,33 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
elevated,
|
||||
forScope,
|
||||
noteAccessWhere,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
projectCardAccessWhere,
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import {
|
||||
BILLING_PLAN_LABELS,
|
||||
BUSINESS_FIELD_LABELS,
|
||||
PERSON_IMPORT_SOURCE_LABELS,
|
||||
USER_ROLE_LABELS,
|
||||
brand,
|
||||
brandSuggestionFa,
|
||||
isBusinessField,
|
||||
type BillingPlanCode,
|
||||
type PersonImportSource,
|
||||
type UserRole,
|
||||
} from '@mento/shared';
|
||||
import { startOfJalaliMonth, toJalali } from '../common/jalali';
|
||||
import { AiService } from './ai.service';
|
||||
import {
|
||||
classifyCountQuestion,
|
||||
personSourceKeys,
|
||||
type CountIntent,
|
||||
} from './ask-count';
|
||||
|
||||
type CatalogItem = AskMentoSourceDto;
|
||||
|
||||
@@ -136,6 +147,9 @@ export class AiAskService {
|
||||
personId?: string,
|
||||
chatContext?: string,
|
||||
): Promise<AskMentoResponse> {
|
||||
const direct = await this.directCount(actor, question);
|
||||
if (direct) return { answer: direct, sources: [] };
|
||||
|
||||
const { context, catalog } = await this.buildContext(actor, personId);
|
||||
const chatBlock = String(chatContext || '')
|
||||
.trim()
|
||||
@@ -146,10 +160,13 @@ export class AiAskService {
|
||||
}>(
|
||||
`You are ${brand.name}, a Persian CRM assistant for this user's account.
|
||||
"محیط" means a workspace/organization the user belongs to.
|
||||
"مشتری" / "مشتری جدید" means a Person (contact) record.
|
||||
"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total.
|
||||
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، تهحساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
|
||||
Lead stage and lead source lines are authoritative the same way.
|
||||
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
|
||||
Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets.
|
||||
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable.
|
||||
Workspace facts and Period stats are complete and authoritative for counts.
|
||||
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
|
||||
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. If a requested filter is not in the facts, say that filtered count is not available instead of substituting another total.
|
||||
If a "Recent conversation" block is provided, use it only to understand the user's question and tone; do not invent CRM facts from chat alone.
|
||||
Do not invent customers, amounts, dates, or promises.
|
||||
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
|
||||
@@ -203,6 +220,333 @@ Rules:
|
||||
};
|
||||
}
|
||||
|
||||
private leadWhere(actor: Actor) {
|
||||
const scoped = forScope(actor, 'leads');
|
||||
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
||||
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
||||
return { assigneeId: { in: scoped.visibleUserIds } };
|
||||
}
|
||||
return { assigneeId: scoped.userId };
|
||||
}
|
||||
|
||||
private fa(n: number) {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
private periodRange(key: NonNullable<CountIntent['period']>) {
|
||||
const periods = tehranPeriods();
|
||||
if (key === 'today') return periods.today;
|
||||
if (key === 'yesterday') return periods.yesterday;
|
||||
if (key === 'week') return periods.week;
|
||||
return periods.month;
|
||||
}
|
||||
|
||||
private async directCount(actor: Actor, question: string): Promise<string | null> {
|
||||
const intent = classifyCountQuestion(question);
|
||||
if (!intent) return null;
|
||||
const orgId = actor.organizationId;
|
||||
const created =
|
||||
intent.period != null
|
||||
? {
|
||||
createdAt: {
|
||||
gte: this.periodRange(intent.period).start,
|
||||
lte: this.periodRange(intent.period).end,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const periodNote =
|
||||
intent.period === 'today'
|
||||
? 'امروز'
|
||||
: intent.period === 'yesterday'
|
||||
? 'دیروز'
|
||||
: intent.period === 'week'
|
||||
? 'این هفته'
|
||||
: intent.period === 'month'
|
||||
? 'این ماه'
|
||||
: '';
|
||||
|
||||
if (intent.entity === 'person' || intent.entity === 'company') {
|
||||
const where = {
|
||||
organizationId: orgId,
|
||||
...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)),
|
||||
...(intent.personSource ? { importSource: intent.personSource } : {}),
|
||||
...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}),
|
||||
...created,
|
||||
};
|
||||
const n =
|
||||
intent.entity === 'person'
|
||||
? await this.prisma.person.count({ where })
|
||||
: await this.prisma.company.count({ where });
|
||||
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
|
||||
const source = intent.personSource
|
||||
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
|
||||
: '';
|
||||
const mood =
|
||||
intent.mood === 'happy'
|
||||
? 'خوشحال'
|
||||
: intent.mood === 'unhappy'
|
||||
? 'ناراضی'
|
||||
: intent.mood === 'special'
|
||||
? 'خاص'
|
||||
: intent.mood === 'newcomer'
|
||||
? 'تازهوارد'
|
||||
: '';
|
||||
const bits = [source, mood, periodNote].filter(Boolean).join(' ');
|
||||
const label = bits ? `${who}های ${bits}` : `کل ${who}ها`;
|
||||
return `${label}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
if (intent.entity === 'lead') {
|
||||
return this.directLeadCount(actor, intent, created, periodNote);
|
||||
}
|
||||
|
||||
if (intent.entity === 'deal') {
|
||||
const scope = dealAccessWhere(actor);
|
||||
const where = {
|
||||
organizationId: orgId,
|
||||
...scope,
|
||||
...created,
|
||||
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
|
||||
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
|
||||
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
|
||||
...(intent.dealBucket === 'stale'
|
||||
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
|
||||
: {}),
|
||||
};
|
||||
const n = await this.prisma.deal.count({ where });
|
||||
const name =
|
||||
intent.dealBucket === 'open'
|
||||
? 'معاملات باز'
|
||||
: intent.dealBucket === 'won'
|
||||
? 'معاملات برنده'
|
||||
: intent.dealBucket === 'lost'
|
||||
? 'معاملات ازدسترفته'
|
||||
: intent.dealBucket === 'stale'
|
||||
? 'معاملات در خطر'
|
||||
: 'کل معاملات';
|
||||
return `${periodNote ? `${name} ${periodNote}` : name}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
const simple: Record<string, () => Promise<number>> = {
|
||||
invoice: () =>
|
||||
this.prisma.invoice.count({
|
||||
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
|
||||
}),
|
||||
quote: () =>
|
||||
this.prisma.quote.count({
|
||||
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
|
||||
}),
|
||||
product: () =>
|
||||
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
|
||||
activity: () =>
|
||||
this.prisma.activity.count({
|
||||
where: { organizationId: orgId, ...assigneeWhere(actor), ...created },
|
||||
}),
|
||||
ticket: () =>
|
||||
this.prisma.ticket.count({
|
||||
where: { organizationId: orgId, ...ticketAccessWhere(actor), ...created },
|
||||
}),
|
||||
note: () =>
|
||||
this.prisma.note.count({
|
||||
where: { organizationId: orgId, ...noteAccessWhere(actor), ...created },
|
||||
}),
|
||||
cheque: () => this.prisma.cheque.count({ where: { organizationId: orgId, ...created } }),
|
||||
reservation: () =>
|
||||
this.prisma.reservationAppointment.count({
|
||||
where: { organizationId: orgId, ...created },
|
||||
}),
|
||||
routine: () =>
|
||||
this.prisma.routineTask.count({ where: { organizationId: orgId, ...created } }),
|
||||
project: () =>
|
||||
this.prisma.projectCard.count({
|
||||
where: { organizationId: orgId, ...projectCardAccessWhere(actor), ...created },
|
||||
}),
|
||||
};
|
||||
const counter = simple[intent.entity];
|
||||
if (!counter) return null;
|
||||
const labels: Record<string, string> = {
|
||||
invoice: 'فاکتور',
|
||||
quote: 'پیشفاکتور',
|
||||
product: 'محصول',
|
||||
activity: 'فعالیت',
|
||||
ticket: 'تیکت',
|
||||
note: 'یادداشت',
|
||||
cheque: 'چک',
|
||||
reservation: 'رزرو',
|
||||
routine: 'کار روتین',
|
||||
project: 'کارت پروژه',
|
||||
};
|
||||
const n = await counter();
|
||||
const name = labels[intent.entity] ?? intent.entity;
|
||||
return `${periodNote ? `${name}های ${periodNote}` : `کل ${name}ها`}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
private async directLeadCount(
|
||||
actor: Actor,
|
||||
intent: CountIntent,
|
||||
created: { createdAt?: { gte: Date; lte: Date } },
|
||||
periodNote: string,
|
||||
) {
|
||||
const orgId = actor.organizationId;
|
||||
const base = { organizationId: orgId, ...this.leadWhere(actor), ...created };
|
||||
const [stages, sources] = await Promise.all([
|
||||
this.prisma.leadStage.findMany({
|
||||
where: { organizationId: orgId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['source'],
|
||||
where: base,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const compact = (value: string) => value.replace(/[\s]+/g, '').toLowerCase();
|
||||
const hint = compact(intent.hint).length >= 2 ? compact(intent.hint) : '';
|
||||
const stage = hint
|
||||
? stages.find((row) => {
|
||||
const name = compact(row.name);
|
||||
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
|
||||
})
|
||||
: undefined;
|
||||
const sourceRow = hint
|
||||
? sources.find((row) => {
|
||||
const name = compact(row.source ?? '');
|
||||
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (hint && !stage && !sourceRow && intent.leadBucket === 'summary') {
|
||||
return `لیدی با فیلتر «${intent.hint}» پیدا نشد: ${this.fa(0)} تا.`;
|
||||
}
|
||||
|
||||
if (intent.leadBucket === 'summary' && !stage && !sourceRow) {
|
||||
const [open, archived, converted, total] = await Promise.all([
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, archivedAt: null, convertedPersonId: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: { ...base, archivedAt: { not: null } } }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, convertedPersonId: { not: null }, archivedAt: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: base }),
|
||||
]);
|
||||
const when = periodNote ? ` ${periodNote}` : '';
|
||||
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
||||
}
|
||||
|
||||
const where = {
|
||||
...base,
|
||||
...(intent.leadBucket === 'archived' ? { archivedAt: { not: null } } : {}),
|
||||
...(intent.leadBucket === 'converted'
|
||||
? { convertedPersonId: { not: null }, archivedAt: null }
|
||||
: {}),
|
||||
...(intent.leadBucket === 'open'
|
||||
? { archivedAt: null, convertedPersonId: null }
|
||||
: {}),
|
||||
...(stage ? { stageId: stage.id } : {}),
|
||||
...(sourceRow ? { source: sourceRow.source } : {}),
|
||||
};
|
||||
const n = await this.prisma.lead.count({ where });
|
||||
const bits = [
|
||||
stage?.name,
|
||||
sourceRow?.source || '',
|
||||
intent.leadBucket === 'archived'
|
||||
? 'بایگانی'
|
||||
: intent.leadBucket === 'converted'
|
||||
? 'تبدیلشده'
|
||||
: intent.leadBucket === 'open'
|
||||
? 'باز'
|
||||
: '',
|
||||
periodNote,
|
||||
].filter(Boolean);
|
||||
return `لیدهای ${bits.join(' ')}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
private async filterFactLines(actor: Actor) {
|
||||
const orgId = actor.organizationId;
|
||||
const personScope = ownerWhere(actor);
|
||||
const companyScope = ownerWhere(actor);
|
||||
const leadBase = { organizationId: orgId, ...this.leadWhere(actor) };
|
||||
const [peopleSources, companySources, stages, leadSources, leadCounts, extras] =
|
||||
await Promise.all([
|
||||
this.prisma.person.groupBy({
|
||||
by: ['importSource'],
|
||||
where: { organizationId: orgId, ...personScope },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.company.groupBy({
|
||||
by: ['importSource'],
|
||||
where: { organizationId: orgId, ...companyScope },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.leadStage.findMany({
|
||||
where: { organizationId: orgId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { position: 'asc' },
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['source'],
|
||||
where: leadBase,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
Promise.all([
|
||||
this.prisma.lead.count({ where: leadBase }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: { ...leadBase, archivedAt: { not: null } } }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...leadBase, convertedPersonId: { not: null }, archivedAt: null },
|
||||
}),
|
||||
]),
|
||||
Promise.all([
|
||||
this.prisma.cheque.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.reservationAppointment.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.routineTask.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.projectCard.count({ where: { organizationId: orgId } }),
|
||||
]),
|
||||
]);
|
||||
|
||||
const sourceLine = (
|
||||
label: string,
|
||||
rows: Array<{ importSource: string; _count: { _all: number } }>,
|
||||
) => {
|
||||
const parts = personSourceKeys().map((key) => {
|
||||
const hit = rows.find((row) => (row.importSource || 'manual') === key);
|
||||
const name = PERSON_IMPORT_SOURCE_LABELS[key as PersonImportSource];
|
||||
return `${name}/${key}=${hit?._count._all ?? 0}`;
|
||||
});
|
||||
return `${label}: ${parts.join(' | ')}`;
|
||||
};
|
||||
|
||||
const stageCounts = await this.prisma.lead.groupBy({
|
||||
by: ['stageId'],
|
||||
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const stageLine = stages
|
||||
.map((stage) => {
|
||||
const hit = stageCounts.find((row) => row.stageId === stage.id);
|
||||
return `${stage.name}=${hit?._count._all ?? 0}`;
|
||||
})
|
||||
.join(' | ');
|
||||
const sourceBits = leadSources
|
||||
.map((row) => `${row.source?.trim() || '(بدون منبع)'}=${row._count._all}`)
|
||||
.join(' | ');
|
||||
const [leadTotal, leadOpen, leadArchived, leadConverted] = leadCounts;
|
||||
const [cheques, reservations, routines, projects] = extras;
|
||||
|
||||
return [
|
||||
'People by import source (مشتری؛ دستی=manual). Use the matching source, never the people total, when a source is named:',
|
||||
sourceLine('People sources', peopleSources),
|
||||
sourceLine('Company sources', companySources),
|
||||
`Leads are NOT people. open=${leadOpen} | archived=${leadArchived} | converted=${leadConverted} | total=${leadTotal}`,
|
||||
`Lead stages (open only): ${stageLine || 'none'}`,
|
||||
`Lead sources: ${sourceBits || 'none'}`,
|
||||
`Other totals: cheques=${cheques} | reservations=${reservations} | routine tasks=${routines} | project cards=${projects}`,
|
||||
];
|
||||
}
|
||||
|
||||
private async countInPeriods(
|
||||
count: (start: Date, end: Date) => Promise<number>,
|
||||
periods: ReturnType<typeof tehranPeriods>,
|
||||
@@ -589,6 +933,7 @@ Rules:
|
||||
`Environments: ${envLines.join(' | ') || 'none'}`,
|
||||
`Current environment: ${org?.name ?? '—'} | business field: ${field} | team members: ${teamCount}`,
|
||||
`Totals: people=${peopleTotal} | companies=${companiesTotal} | products=${productsTotal} | open deals=${openDealCount} | at-risk deals=${staleDealCount} | overdue activities=${overdueActivityCount} | activities due today=${activitiesDueToday}`,
|
||||
...(await this.filterFactLines(actor)),
|
||||
'Period stats (created/registered unless noted):',
|
||||
formatPeriodCounts('People / مشتریان جدید', peoplePeriods),
|
||||
peopleTodayNames.length
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
PERSON_IMPORT_SOURCES,
|
||||
type PersonImportSource,
|
||||
} from '@mento/shared';
|
||||
|
||||
export type CountEntity =
|
||||
| 'lead'
|
||||
| 'person'
|
||||
| 'company'
|
||||
| 'deal'
|
||||
| 'invoice'
|
||||
| 'quote'
|
||||
| 'product'
|
||||
| 'activity'
|
||||
| 'ticket'
|
||||
| 'note'
|
||||
| 'cheque'
|
||||
| 'reservation'
|
||||
| 'routine'
|
||||
| 'project';
|
||||
|
||||
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'month';
|
||||
|
||||
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
|
||||
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
|
||||
|
||||
export type CountIntent = {
|
||||
entity: CountEntity;
|
||||
personSource?: PersonImportSource;
|
||||
mood?: 'newcomer' | 'happy' | 'unhappy' | 'special';
|
||||
leadBucket: LeadBucket;
|
||||
dealBucket: DealBucket;
|
||||
period?: CountPeriod;
|
||||
/** متن باقیمانده برای تطبیق مرحله یا منبع لید */
|
||||
hint: string;
|
||||
};
|
||||
|
||||
const SOURCE_PATTERNS: Array<{ re: RegExp; source: PersonImportSource }> = [
|
||||
{ re: /دستی/, source: 'manual' },
|
||||
{ re: /اکسل/, source: 'excel' },
|
||||
{ re: /دیدار/, source: 'didar' },
|
||||
{ re: /ته\s*حساب/, source: 'tahesab' },
|
||||
{ re: /گوشی|مخاطب\s*گوشی|مخاطبین/, source: 'phone' },
|
||||
{ re: /فرم/, source: 'lead_form' },
|
||||
{ re: /مونتیفای/, source: 'montify' },
|
||||
{ re: /سایر|بقیه/, source: 'other' },
|
||||
];
|
||||
|
||||
export function normalizeAskText(raw: string) {
|
||||
return raw
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(//g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isCountQuestion(q: string) {
|
||||
return /چند|تعداد|چقدر|چنتا|چند\s*تا/.test(q);
|
||||
}
|
||||
|
||||
function detectEntity(q: string): CountEntity | null {
|
||||
if (/لید|سرنخ/.test(q)) return 'lead';
|
||||
if (/پیش\s*فاکتور/.test(q)) return 'quote';
|
||||
if (/فاکتور/.test(q)) return 'invoice';
|
||||
if (/معامله/.test(q)) return 'deal';
|
||||
if (/چک/.test(q)) return 'cheque';
|
||||
if (/رزرو|نوبت/.test(q)) return 'reservation';
|
||||
if (/روتین/.test(q)) return 'routine';
|
||||
if (/پروژه/.test(q)) return 'project';
|
||||
if (/تیکت/.test(q)) return 'ticket';
|
||||
if (/یادداشت/.test(q)) return 'note';
|
||||
if (/فعالیت/.test(q)) return 'activity';
|
||||
if (/محصول|کالا/.test(q)) return 'product';
|
||||
if (/شرکت/.test(q) && !/مشتری/.test(q)) return 'company';
|
||||
if (/مشتری|اشخاص|شخص ها|افراد|مخاطب/.test(q)) return 'person';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectPeriod(q: string): CountPeriod | undefined {
|
||||
if (/دیروز/.test(q)) return 'yesterday';
|
||||
if (/امروز/.test(q)) return 'today';
|
||||
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
|
||||
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function detectLeadBucket(q: string): LeadBucket {
|
||||
if (/بایگانی/.test(q)) return 'archived';
|
||||
if (/تبدیل/.test(q)) return 'converted';
|
||||
if (/باز|فعال|جاری/.test(q)) return 'open';
|
||||
return 'summary';
|
||||
}
|
||||
|
||||
function detectDealBucket(q: string): DealBucket {
|
||||
if (/در\s*خطر|راکد|گندیده/.test(q)) return 'stale';
|
||||
if (/برنده|موفق|بسته\s*شده\s*موفق/.test(q)) return 'won';
|
||||
if (/باخته|از\s*دست\s*رفته|ناموفق/.test(q)) return 'lost';
|
||||
if (/باز|جاری|فعال/.test(q)) return 'open';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function detectMood(q: string): CountIntent['mood'] {
|
||||
if (/ناراضی/.test(q)) return 'unhappy';
|
||||
if (/خوشحال/.test(q)) return 'happy';
|
||||
if (/خاص/.test(q)) return 'special';
|
||||
if (/تازه/.test(q)) return 'newcomer';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const HINT_STOP = [
|
||||
'چند تا',
|
||||
'ته حساب',
|
||||
'مخاطب گوشی',
|
||||
'مخاطبین',
|
||||
'پیش فاکتور',
|
||||
'بایگانی',
|
||||
'مونتیفای',
|
||||
'معاملات',
|
||||
'مشتریان',
|
||||
'سرنخ',
|
||||
'لیدها',
|
||||
'فعالیت',
|
||||
'یادداشت',
|
||||
'بگویید',
|
||||
'داریم',
|
||||
'دارید',
|
||||
'هستند',
|
||||
'تعداد',
|
||||
'چقدر',
|
||||
'چنتا',
|
||||
'امروز',
|
||||
'دیروز',
|
||||
'جاری',
|
||||
'تبدیل',
|
||||
'برنده',
|
||||
'باخته',
|
||||
'ناراضی',
|
||||
'خوشحال',
|
||||
'محصول',
|
||||
'فاکتور',
|
||||
'معامله',
|
||||
'مشتری',
|
||||
'شرکت',
|
||||
'پروژه',
|
||||
'روتین',
|
||||
'رزرو',
|
||||
'نوبت',
|
||||
'تیکت',
|
||||
'منبع',
|
||||
'مرحله',
|
||||
'وضعیت',
|
||||
'هفته',
|
||||
'لطفا',
|
||||
'الان',
|
||||
'شده',
|
||||
'وارد',
|
||||
'تازه',
|
||||
'خاص',
|
||||
'بگو',
|
||||
'دارم',
|
||||
'هست',
|
||||
'است',
|
||||
'جاری',
|
||||
'فعال',
|
||||
'باز',
|
||||
'لید',
|
||||
'کالا',
|
||||
'چک',
|
||||
'ماه',
|
||||
'پیش',
|
||||
'های',
|
||||
'چند',
|
||||
'کل',
|
||||
'همه',
|
||||
'این',
|
||||
'یک',
|
||||
'یه',
|
||||
'از',
|
||||
'با',
|
||||
'در',
|
||||
'تا',
|
||||
'ها',
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
const PERSON_HINT_STOP = [
|
||||
'ته حساب',
|
||||
'مخاطب گوشی',
|
||||
'مخاطبین',
|
||||
'مونتیفای',
|
||||
'ناراضی',
|
||||
'خوشحال',
|
||||
'دستی',
|
||||
'اکسل',
|
||||
'دیدار',
|
||||
'گوشی',
|
||||
'مخاطب',
|
||||
'فرم',
|
||||
'سایر',
|
||||
'تازه',
|
||||
'خاص',
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
function leftoverHint(q: string, entity: CountEntity) {
|
||||
let text = ` ${q} `;
|
||||
const words = entity === 'person' || entity === 'company'
|
||||
? [...HINT_STOP, ...PERSON_HINT_STOP]
|
||||
: HINT_STOP;
|
||||
for (const word of [...words].sort((a, b) => b.length - a.length)) {
|
||||
text = text.replaceAll(word, ' ');
|
||||
}
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function classifyCountQuestion(raw: string): CountIntent | null {
|
||||
const q = normalizeAskText(raw);
|
||||
if (!isCountQuestion(q)) return null;
|
||||
const entity = detectEntity(q);
|
||||
if (!entity) return null;
|
||||
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
|
||||
return {
|
||||
entity,
|
||||
personSource:
|
||||
entity === 'person' || entity === 'company' ? personSource : undefined,
|
||||
mood: entity === 'person' ? detectMood(q) : undefined,
|
||||
leadBucket: detectLeadBucket(q),
|
||||
dealBucket: detectDealBucket(q),
|
||||
period: detectPeriod(q),
|
||||
hint: leftoverHint(q, entity),
|
||||
};
|
||||
}
|
||||
|
||||
export function personSourceKeys(): readonly PersonImportSource[] {
|
||||
return PERSON_IMPORT_SOURCES;
|
||||
}
|
||||
Reference in New Issue
Block a user