Configure the AI assistant for more information.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
assigneeWhere,
|
||||
noteAccessWhere,
|
||||
ownerWhere,
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import {
|
||||
BILLING_PLAN_LABELS,
|
||||
@@ -21,10 +22,20 @@ import {
|
||||
type BillingPlanCode,
|
||||
type UserRole,
|
||||
} from '@mento/shared';
|
||||
import { startOfJalaliMonth, toJalali } from '../common/jalali';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
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) {
|
||||
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
@@ -39,6 +50,75 @@ function planLabel(code: string) {
|
||||
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()
|
||||
export class AiAskService {
|
||||
constructor(
|
||||
@@ -57,12 +137,14 @@ export class AiAskService {
|
||||
sources?: Array<{ type?: string; id?: string; label?: string }>;
|
||||
}>(
|
||||
`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).
|
||||
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.
|
||||
Answer ONLY from the CRM context. Never say a workspace fact is unavailable if it appears in the context.
|
||||
"محیط" means a workspace/organization the user belongs to.
|
||||
"مشتری" / "مشتری جدید" means a Person (contact) record.
|
||||
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.
|
||||
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.`,
|
||||
`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) {
|
||||
if (personId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
@@ -121,33 +216,52 @@ Rules:
|
||||
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayEnd = new Date(now);
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
const periods = tehranPeriods();
|
||||
const { now, dayKey } = periods;
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor);
|
||||
const activityScope = assigneeWhere(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 [
|
||||
org,
|
||||
user,
|
||||
memberships,
|
||||
ownerBilling,
|
||||
peopleCount,
|
||||
teamCount,
|
||||
peopleTotal,
|
||||
companiesTotal,
|
||||
productsTotal,
|
||||
openDealCount,
|
||||
staleDealCount,
|
||||
overdueActivityCount,
|
||||
todayActivityCount,
|
||||
teamCount,
|
||||
activitiesDueToday,
|
||||
peoplePeriods,
|
||||
companyPeriods,
|
||||
activityPeriods,
|
||||
dealCreatedPeriods,
|
||||
dealWonPeriods,
|
||||
invoicePeriods,
|
||||
quotePeriods,
|
||||
productPeriods,
|
||||
notePeriods,
|
||||
ticketPeriods,
|
||||
peopleToday,
|
||||
recentPeople,
|
||||
notes,
|
||||
activities,
|
||||
deals,
|
||||
memories,
|
||||
] = await Promise.all([
|
||||
this.prisma.organization.findUnique({
|
||||
where: { id: actor.organizationId },
|
||||
where: { id: orgId },
|
||||
select: { name: true, businessField: true },
|
||||
}),
|
||||
this.prisma.user.findUnique({
|
||||
@@ -172,7 +286,7 @@ Rules:
|
||||
owner: {
|
||||
memberships: {
|
||||
some: {
|
||||
organizationId: actor.organizationId,
|
||||
organizationId: orgId,
|
||||
role: 'owner',
|
||||
isActive: true,
|
||||
},
|
||||
@@ -186,19 +300,24 @@ Rules:
|
||||
maxOrganizations: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.membership.count({
|
||||
where: { organizationId: orgId, isActive: true },
|
||||
}),
|
||||
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({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
status: 'open',
|
||||
...dealScope,
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.count({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
...dealScope,
|
||||
OR: [{ isStale: true }, { isRotten: true }],
|
||||
@@ -206,7 +325,7 @@ Rules:
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
status: 'open',
|
||||
dueAt: { lt: now },
|
||||
@@ -214,18 +333,157 @@ Rules:
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
status: 'open',
|
||||
dueAt: { gte: todayStart, lte: todayEnd },
|
||||
dueAt: { gte: periods.today.start, lte: periods.today.end },
|
||||
},
|
||||
}),
|
||||
this.prisma.membership.count({
|
||||
where: { organizationId: actor.organizationId, isActive: true },
|
||||
this.countInPeriods(
|
||||
(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({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
organizationId: orgId,
|
||||
...noteAccessWhere(actor),
|
||||
...(personId ? { personId } : {}),
|
||||
},
|
||||
@@ -241,7 +499,7 @@ Rules:
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
organizationId: orgId,
|
||||
...assigneeWhere(actor),
|
||||
...(personId ? { personId } : {}),
|
||||
},
|
||||
@@ -259,7 +517,7 @@ Rules:
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
organizationId: orgId,
|
||||
...ownerWhere(actor),
|
||||
...(personId ? { personId } : { status: 'open' }),
|
||||
},
|
||||
@@ -279,7 +537,7 @@ Rules:
|
||||
}),
|
||||
personId
|
||||
? this.prisma.memoryItem.findMany({
|
||||
where: { organizationId: actor.organizationId, personId },
|
||||
where: { organizationId: orgId, personId },
|
||||
select: { id: true, content: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 8,
|
||||
@@ -293,7 +551,7 @@ Rules:
|
||||
? BUSINESS_FIELD_LABELS[org.businessField]
|
||||
: 'نامشخص';
|
||||
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)})`;
|
||||
});
|
||||
const billingDays = ownerBilling
|
||||
@@ -305,13 +563,33 @@ Rules:
|
||||
),
|
||||
)
|
||||
: null;
|
||||
const peopleTodayNames = peopleToday
|
||||
.map((p) => `${p.firstName} ${p.lastName}`.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
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):',
|
||||
`User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`,
|
||||
`Environments (محیط) count: ${memberships.length}`,
|
||||
`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) {
|
||||
lines.push(
|
||||
@@ -319,12 +597,14 @@ Rules:
|
||||
);
|
||||
}
|
||||
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) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId },
|
||||
where: { id: personId, organizationId: orgId },
|
||||
include: { company: { select: { name: true } } },
|
||||
});
|
||||
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 ?? '—'}`,
|
||||
);
|
||||
}
|
||||
} 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) {
|
||||
@@ -381,6 +672,6 @@ Rules:
|
||||
);
|
||||
}
|
||||
|
||||
return { context: lines.join('\n').slice(0, 12000), catalog };
|
||||
return { context: lines.join('\n').slice(0, 14000), catalog };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user