Configure the AI assistant for more information.

This commit is contained in:
2026-09-21 19:24:39 +03:30
parent 7a58b9d469
commit f4cb41d96f
+329 -38
View File
@@ -10,6 +10,7 @@ import {
assigneeWhere, assigneeWhere,
noteAccessWhere, noteAccessWhere,
ownerWhere, ownerWhere,
ticketAccessWhere,
} from '../common/record-scope'; } from '../common/record-scope';
import { import {
BILLING_PLAN_LABELS, BILLING_PLAN_LABELS,
@@ -21,10 +22,20 @@ import {
type BillingPlanCode, type BillingPlanCode,
type UserRole, type UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { startOfJalaliMonth, toJalali } from '../common/jalali';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
type CatalogItem = AskMentoSourceDto; type CatalogItem = AskMentoSourceDto;
type PeriodRange = { key: string; label: string; start: Date; end: Date };
type PeriodCounts = {
today: number;
yesterday: number;
week: number;
month: number;
};
function clip(value: string | null | undefined, max = 280) { function clip(value: string | null | undefined, max = 280) {
const text = (value ?? '').replace(/\s+/g, ' ').trim(); const text = (value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return ''; if (!text) return '';
@@ -39,6 +50,75 @@ function planLabel(code: string) {
return BILLING_PLAN_LABELS[code as BillingPlanCode] ?? code; return BILLING_PLAN_LABELS[code as BillingPlanCode] ?? code;
} }
function addDaysToDayKey(dayKey: string, days: number): string {
const [y, m, d] = dayKey.split('-').map(Number);
const dt = new Date(Date.UTC(y!, m! - 1, d!));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}
function dayBoundsFromKey(dayKey: string) {
return {
start: new Date(`${dayKey}T00:00:00+03:30`),
end: new Date(`${dayKey}T23:59:59.999+03:30`),
};
}
/** بازه‌های تقویمی ایران: امروز، دیروز، این هفته (شنبه تا جمعه)، این ماه شمسی */
function tehranPeriods(now = new Date()) {
const dayKey = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const weekday = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
weekday: 'short',
}).format(now);
const sinceSaturday: Record<string, number> = {
Sat: 0,
Sun: 1,
Mon: 2,
Tue: 3,
Wed: 4,
Thu: 5,
Fri: 6,
};
const weekOffset = sinceSaturday[weekday] ?? 0;
const yesterdayKey = addDaysToDayKey(dayKey, -1);
const weekStartKey = addDaysToDayKey(dayKey, -weekOffset);
const weekEndKey = addDaysToDayKey(weekStartKey, 6);
const today = { key: dayKey, label: 'today', ...dayBoundsFromKey(dayKey) };
const yesterday = {
key: yesterdayKey,
label: 'yesterday',
...dayBoundsFromKey(yesterdayKey),
};
const week: PeriodRange = {
key: `${weekStartKey}..${weekEndKey}`,
label: 'this_week',
start: dayBoundsFromKey(weekStartKey).start,
end: dayBoundsFromKey(weekEndKey).end,
};
const { jy, jm } = (() => {
const [gy, gm, gd] = dayKey.split('-').map(Number);
return toJalali(gy!, gm!, gd!);
})();
const monthStart = startOfJalaliMonth(jy, jm);
const month: PeriodRange = {
key: `${jy}-${String(jm).padStart(2, '0')}`,
label: 'this_jalali_month',
start: monthStart,
end: now,
};
return { now, dayKey, today, yesterday, week, month };
}
function formatPeriodCounts(labelFa: string, counts: PeriodCounts) {
return `${labelFa}: today=${counts.today} | yesterday=${counts.yesterday} | this_week=${counts.week} | this_month=${counts.month}`;
}
@Injectable() @Injectable()
export class AiAskService { export class AiAskService {
constructor( constructor(
@@ -57,12 +137,14 @@ export class AiAskService {
sources?: Array<{ type?: string; id?: string; label?: string }>; sources?: Array<{ type?: string; id?: string; label?: string }>;
}>( }>(
`You are ${brand.name}, a Persian CRM assistant for this user's account. `You are ${brand.name}, a Persian CRM assistant for this user's account.
"محیط" means a workspace/organization the user belongs to (listed under Workspace facts). "محیط" means a workspace/organization the user belongs to.
Workspace facts are complete and authoritative. Use them for questions about how many environments the user has, the current environment, role, subscription, team size, and record counts. "مشتری" / "مشتری جدید" means a Person (contact) record.
Answer ONLY from the CRM context. Never say a workspace fact is unavailable if it appears in the context. 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. Never say a listed count is unavailable.
Do not invent customers, amounts, dates, or promises. Do not invent customers, amounts, dates, or promises.
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] } Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
Use at most 4 sources, and only ids that appear in the context. Workspace facts do not need sources. Use at most 4 sources, and only ids that appear in the context. Workspace/period facts do not need sources; when naming customers, prefer person sources.
Write the answer in Persian, concise, with exact numbers and names when present.`, Write the answer in Persian, concise, with exact numbers and names when present.`,
`Question:\n${question.trim()}\n\nCRM context:\n${context}`, `Question:\n${question.trim()}\n\nCRM context:\n${context}`,
); );
@@ -108,6 +190,19 @@ Rules:
}; };
} }
private async countInPeriods(
count: (start: Date, end: Date) => Promise<number>,
periods: ReturnType<typeof tehranPeriods>,
): Promise<PeriodCounts> {
const [today, yesterday, week, month] = await Promise.all([
count(periods.today.start, periods.today.end),
count(periods.yesterday.start, periods.yesterday.end),
count(periods.week.start, periods.week.end),
count(periods.month.start, periods.month.end),
]);
return { today, yesterday, week, month };
}
private async buildContext(actor: Actor, personId?: string) { private async buildContext(actor: Actor, personId?: string) {
if (personId) { if (personId) {
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
@@ -121,33 +216,52 @@ Rules:
if (!person) throw new NotFoundException('شخص پیدا نشد'); if (!person) throw new NotFoundException('شخص پیدا نشد');
} }
const now = new Date(); const periods = tehranPeriods();
const todayStart = new Date(now); const { now, dayKey } = periods;
todayStart.setHours(0, 0, 0, 0); const orgId = actor.organizationId;
const todayEnd = new Date(now);
todayEnd.setHours(23, 59, 59, 999);
const dealScope = ownerWhere(actor); const dealScope = ownerWhere(actor);
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const personScope = ownerWhere(actor); const personScope = ownerWhere(actor);
const companyScope = ownerWhere(actor);
const invoiceScope = ownerWhere(actor);
const quoteScope = ownerWhere(actor);
const ticketScope = ticketAccessWhere(actor);
const createdBetween =
(start: Date, end: Date) => ({ createdAt: { gte: start, lte: end } });
const [ const [
org, org,
user, user,
memberships, memberships,
ownerBilling, ownerBilling,
peopleCount, teamCount,
peopleTotal,
companiesTotal,
productsTotal,
openDealCount, openDealCount,
staleDealCount, staleDealCount,
overdueActivityCount, overdueActivityCount,
todayActivityCount, activitiesDueToday,
teamCount, peoplePeriods,
companyPeriods,
activityPeriods,
dealCreatedPeriods,
dealWonPeriods,
invoicePeriods,
quotePeriods,
productPeriods,
notePeriods,
ticketPeriods,
peopleToday,
recentPeople,
notes, notes,
activities, activities,
deals, deals,
memories, memories,
] = await Promise.all([ ] = await Promise.all([
this.prisma.organization.findUnique({ this.prisma.organization.findUnique({
where: { id: actor.organizationId }, where: { id: orgId },
select: { name: true, businessField: true }, select: { name: true, businessField: true },
}), }),
this.prisma.user.findUnique({ this.prisma.user.findUnique({
@@ -172,7 +286,7 @@ Rules:
owner: { owner: {
memberships: { memberships: {
some: { some: {
organizationId: actor.organizationId, organizationId: orgId,
role: 'owner', role: 'owner',
isActive: true, isActive: true,
}, },
@@ -186,19 +300,24 @@ Rules:
maxOrganizations: true, maxOrganizations: true,
}, },
}), }),
this.prisma.membership.count({
where: { organizationId: orgId, isActive: true },
}),
this.prisma.person.count({ this.prisma.person.count({
where: { organizationId: actor.organizationId, ...personScope }, where: { organizationId: orgId, ...personScope },
}),
this.prisma.company.count({
where: { organizationId: orgId, ...companyScope },
}),
this.prisma.product.count({
where: { organizationId: orgId },
}),
this.prisma.deal.count({
where: { organizationId: orgId, status: 'open', ...dealScope },
}), }),
this.prisma.deal.count({ this.prisma.deal.count({
where: { where: {
organizationId: actor.organizationId, organizationId: orgId,
status: 'open',
...dealScope,
},
}),
this.prisma.deal.count({
where: {
organizationId: actor.organizationId,
status: 'open', status: 'open',
...dealScope, ...dealScope,
OR: [{ isStale: true }, { isRotten: true }], OR: [{ isStale: true }, { isRotten: true }],
@@ -206,7 +325,7 @@ Rules:
}), }),
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
organizationId: actor.organizationId, organizationId: orgId,
...activityScope, ...activityScope,
status: 'open', status: 'open',
dueAt: { lt: now }, dueAt: { lt: now },
@@ -214,18 +333,157 @@ Rules:
}), }),
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
organizationId: actor.organizationId, organizationId: orgId,
...activityScope, ...activityScope,
status: 'open', status: 'open',
dueAt: { gte: todayStart, lte: todayEnd }, dueAt: { gte: periods.today.start, lte: periods.today.end },
}, },
}), }),
this.prisma.membership.count({ this.countInPeriods(
where: { organizationId: actor.organizationId, isActive: true }, (start, end) =>
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.company.count({
where: {
organizationId: orgId,
...companyScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.activity.count({
where: {
organizationId: orgId,
...activityScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.deal.count({
where: {
organizationId: orgId,
...dealScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.deal.count({
where: {
organizationId: orgId,
...dealScope,
status: 'won',
updatedAt: { gte: start, lte: end },
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.invoice.count({
where: {
organizationId: orgId,
...invoiceScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.quote.count({
where: {
organizationId: orgId,
...quoteScope,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.product.count({
where: {
organizationId: orgId,
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.note.count({
where: {
organizationId: orgId,
...noteAccessWhere(actor),
...createdBetween(start, end),
},
}),
periods,
),
this.countInPeriods(
(start, end) =>
this.prisma.ticket.count({
where: {
organizationId: orgId,
...ticketScope,
...createdBetween(start, end),
},
}),
periods,
),
this.prisma.person.findMany({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: periods.today.start, lte: periods.today.end },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.prisma.person.findMany({
where: { organizationId: orgId, ...personScope },
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 8,
}), }),
this.prisma.note.findMany({ this.prisma.note.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: orgId,
...noteAccessWhere(actor), ...noteAccessWhere(actor),
...(personId ? { personId } : {}), ...(personId ? { personId } : {}),
}, },
@@ -241,7 +499,7 @@ Rules:
}), }),
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: orgId,
...assigneeWhere(actor), ...assigneeWhere(actor),
...(personId ? { personId } : {}), ...(personId ? { personId } : {}),
}, },
@@ -259,7 +517,7 @@ Rules:
}), }),
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: orgId,
...ownerWhere(actor), ...ownerWhere(actor),
...(personId ? { personId } : { status: 'open' }), ...(personId ? { personId } : { status: 'open' }),
}, },
@@ -279,7 +537,7 @@ Rules:
}), }),
personId personId
? this.prisma.memoryItem.findMany({ ? this.prisma.memoryItem.findMany({
where: { organizationId: actor.organizationId, personId }, where: { organizationId: orgId, personId },
select: { id: true, content: true, updatedAt: true }, select: { id: true, content: true, updatedAt: true },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 8, take: 8,
@@ -293,7 +551,7 @@ Rules:
? BUSINESS_FIELD_LABELS[org.businessField] ? BUSINESS_FIELD_LABELS[org.businessField]
: 'نامشخص'; : 'نامشخص';
const envLines = memberships.map((row) => { const envLines = memberships.map((row) => {
const current = row.organizationId === actor.organizationId ? ' [current]' : ''; const current = row.organizationId === orgId ? ' [current]' : '';
return `${row.organization.name}${current} (role=${roleLabel(row.role)})`; return `${row.organization.name}${current} (role=${roleLabel(row.role)})`;
}); });
const billingDays = ownerBilling const billingDays = ownerBilling
@@ -305,13 +563,33 @@ Rules:
), ),
) )
: null; : null;
const peopleTodayNames = peopleToday
.map((p) => `${p.firstName} ${p.lastName}`.trim())
.filter(Boolean);
const lines: string[] = [ const lines: string[] = [
`Today: ${now.toISOString()}`, `Today (Asia/Tehran): ${dayKey} | now=${now.toISOString()}`,
`Periods: yesterday=${periods.yesterday.key} | this_week=${periods.week.key} (Sat–Fri) | this_month=${periods.month.key} (Jalali)`,
'Workspace facts (authoritative):', 'Workspace facts (authoritative):',
`User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`, `User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`,
`Environments (محیط) count: ${memberships.length}`, `Environments (محیط) count: ${memberships.length}`,
`Environments: ${envLines.join(' | ') || 'none'}`, `Environments: ${envLines.join(' | ') || 'none'}`,
`Current environment: ${org?.name ?? '—'} | business field: ${field} | team members: ${teamCount} | people: ${peopleCount} | open deals: ${openDealCount} | at-risk deals: ${staleDealCount} | overdue activities: ${overdueActivityCount} | activities due today: ${todayActivityCount}`, `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}`,
'Period stats (created/registered unless noted):',
formatPeriodCounts('People / مشتریان جدید', peoplePeriods),
peopleTodayNames.length
? `New customers today names: ${peopleTodayNames.join('، ')}`
: 'New customers today names: (none)',
formatPeriodCounts('Companies / شرکت‌ها', companyPeriods),
formatPeriodCounts('Activities / فعالیت‌ها (created)', activityPeriods),
formatPeriodCounts('Deals / معاملات (created)', dealCreatedPeriods),
formatPeriodCounts('Won deals / معاملات برنده (updatedAt while won)', dealWonPeriods),
formatPeriodCounts('Invoices / فاکتورها', invoicePeriods),
formatPeriodCounts('Quotes / پیش‌فاکتورها', quotePeriods),
formatPeriodCounts('Products / محصولات', productPeriods),
formatPeriodCounts('Notes / یادداشت‌ها', notePeriods),
formatPeriodCounts('Tickets / تیکت‌ها', ticketPeriods),
]; ];
if (ownerBilling && billingDays != null) { if (ownerBilling && billingDays != null) {
lines.push( lines.push(
@@ -319,12 +597,14 @@ Rules:
); );
} }
lines.push( lines.push(
personId ? `Focus personId: ${personId}` : 'Scope: current user records in the current environment', personId
? `Focus personId: ${personId}`
: 'Scope: current user records in the current environment',
); );
if (personId) { if (personId) {
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId }, where: { id: personId, organizationId: orgId },
include: { company: { select: { name: true } } }, include: { company: { select: { name: true } } },
}); });
if (person) { if (person) {
@@ -337,6 +617,17 @@ Rules:
`Person: ${person.firstName} ${person.lastName} | position: ${person.position ?? '—'} | company: ${person.company?.name ?? '—'} | mood: ${person.mood} | phone: ${person.mobile || person.phone || '—'} | email: ${person.email ?? '—'}`, `Person: ${person.firstName} ${person.lastName} | position: ${person.position ?? '—'} | company: ${person.company?.name ?? '—'} | mood: ${person.mood} | phone: ${person.mobile || person.phone || '—'} | email: ${person.email ?? '—'}`,
); );
} }
} else {
const seenPeople = new Set<string>();
for (const p of [...peopleToday, ...recentPeople]) {
if (seenPeople.has(p.id)) continue;
seenPeople.add(p.id);
const label = `${p.firstName} ${p.lastName}`.trim();
catalog.push({ type: 'person', id: p.id, label });
lines.push(
`Person ${p.id} ${label} created=${p.createdAt.toISOString()} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
);
}
} }
for (const item of memories) { for (const item of memories) {
@@ -381,6 +672,6 @@ Rules:
); );
} }
return { context: lines.join('\n').slice(0, 12000), catalog }; return { context: lines.join('\n').slice(0, 14000), catalog };
} }
} }