387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import type {
|
|
AskMentoResponse,
|
|
AskMentoSourceDto,
|
|
PersonAiSummaryDto,
|
|
} from '@mento/shared';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import {
|
|
type Actor,
|
|
assigneeWhere,
|
|
noteAccessWhere,
|
|
ownerWhere,
|
|
} from '../common/record-scope';
|
|
import {
|
|
BILLING_PLAN_LABELS,
|
|
BUSINESS_FIELD_LABELS,
|
|
USER_ROLE_LABELS,
|
|
brand,
|
|
brandSuggestionFa,
|
|
isBusinessField,
|
|
type BillingPlanCode,
|
|
type UserRole,
|
|
} from '@mento/shared';
|
|
import { AiService } from './ai.service';
|
|
|
|
type CatalogItem = AskMentoSourceDto;
|
|
|
|
function clip(value: string | null | undefined, max = 280) {
|
|
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
|
if (!text) return '';
|
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
}
|
|
|
|
function roleLabel(role: string) {
|
|
return USER_ROLE_LABELS[role as UserRole] ?? role;
|
|
}
|
|
|
|
function planLabel(code: string) {
|
|
return BILLING_PLAN_LABELS[code as BillingPlanCode] ?? code;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AiAskService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly ai: AiService,
|
|
) {}
|
|
|
|
async ask(
|
|
actor: Actor,
|
|
question: string,
|
|
personId?: string,
|
|
): Promise<AskMentoResponse> {
|
|
const { context, catalog } = await this.buildContext(actor, personId);
|
|
const raw = await this.ai.completeJson<{
|
|
answer?: string;
|
|
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.
|
|
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.
|
|
Write the answer in Persian, concise, with exact numbers and names when present.`,
|
|
`Question:\n${question.trim()}\n\nCRM context:\n${context}`,
|
|
);
|
|
|
|
const allowed = new Map(catalog.map((item) => [item.id, item]));
|
|
const sources = (Array.isArray(raw.sources) ? raw.sources : [])
|
|
.map((item) => allowed.get(String(item?.id ?? '')))
|
|
.filter((item): item is CatalogItem => Boolean(item))
|
|
.slice(0, 4);
|
|
|
|
const answer = String(raw.answer ?? '').trim().slice(0, 2000);
|
|
return {
|
|
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
|
|
sources,
|
|
};
|
|
}
|
|
|
|
async summarizePerson(actor: Actor, personId: string): Promise<PersonAiSummaryDto> {
|
|
const { context } = await this.buildContext(actor, personId);
|
|
const raw = await this.ai.completeJson<{
|
|
text?: string;
|
|
cta?: string;
|
|
ctaKind?: string;
|
|
}>(
|
|
`You write a short live briefing for a CRM customer card in Persian.
|
|
Return JSON: { "text": string, "cta": string, "ctaKind": "call"|"activity" }
|
|
Rules:
|
|
- 2 to 4 short sentences from the context only.
|
|
- Mention durable memory, last contact, open deals, or next follow-up when present.
|
|
- cta is one concrete next step, starting with «${brand.suggestionPrefixFa}».
|
|
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
|
|
- Do not invent facts.`,
|
|
`Customer briefing context:\n${context}`,
|
|
);
|
|
|
|
const text = String(raw.text ?? '').trim().slice(0, 700);
|
|
const cta = String(raw.cta ?? '').trim().slice(0, 120);
|
|
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
|
|
return {
|
|
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
|
|
cta: cta || brandSuggestionFa('امروز با مشتری تماس بگیر'),
|
|
ctaKind,
|
|
};
|
|
}
|
|
|
|
private async buildContext(actor: Actor, personId?: string) {
|
|
if (personId) {
|
|
const person = await this.prisma.person.findFirst({
|
|
where: {
|
|
id: personId,
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
include: { company: { select: { name: true } } },
|
|
});
|
|
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 dealScope = ownerWhere(actor);
|
|
const activityScope = assigneeWhere(actor);
|
|
const personScope = ownerWhere(actor);
|
|
|
|
const [
|
|
org,
|
|
user,
|
|
memberships,
|
|
ownerBilling,
|
|
peopleCount,
|
|
openDealCount,
|
|
staleDealCount,
|
|
overdueActivityCount,
|
|
todayActivityCount,
|
|
teamCount,
|
|
notes,
|
|
activities,
|
|
deals,
|
|
memories,
|
|
] = await Promise.all([
|
|
this.prisma.organization.findUnique({
|
|
where: { id: actor.organizationId },
|
|
select: { name: true, businessField: true },
|
|
}),
|
|
this.prisma.user.findUnique({
|
|
where: { id: actor.userId },
|
|
select: { fullName: true, email: true, mobile: true },
|
|
}),
|
|
this.prisma.membership.findMany({
|
|
where: {
|
|
userId: actor.userId,
|
|
isActive: true,
|
|
organization: { isActive: true },
|
|
},
|
|
select: {
|
|
role: true,
|
|
organizationId: true,
|
|
organization: { select: { name: true } },
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
}),
|
|
this.prisma.billingAccount.findFirst({
|
|
where: {
|
|
owner: {
|
|
memberships: {
|
|
some: {
|
|
organizationId: actor.organizationId,
|
|
role: 'owner',
|
|
isActive: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
planCode: true,
|
|
status: true,
|
|
expiresAt: true,
|
|
maxOrganizations: true,
|
|
},
|
|
}),
|
|
this.prisma.person.count({
|
|
where: { organizationId: actor.organizationId, ...personScope },
|
|
}),
|
|
this.prisma.deal.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
status: 'open',
|
|
...dealScope,
|
|
},
|
|
}),
|
|
this.prisma.deal.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
status: 'open',
|
|
...dealScope,
|
|
OR: [{ isStale: true }, { isRotten: true }],
|
|
},
|
|
}),
|
|
this.prisma.activity.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
...activityScope,
|
|
status: 'open',
|
|
dueAt: { lt: now },
|
|
},
|
|
}),
|
|
this.prisma.activity.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
...activityScope,
|
|
status: 'open',
|
|
dueAt: { gte: todayStart, lte: todayEnd },
|
|
},
|
|
}),
|
|
this.prisma.membership.count({
|
|
where: { organizationId: actor.organizationId, isActive: true },
|
|
}),
|
|
this.prisma.note.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
...noteAccessWhere(actor),
|
|
...(personId ? { personId } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
body: true,
|
|
createdAt: true,
|
|
person: { select: { firstName: true, lastName: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: personId ? 15 : 8,
|
|
}),
|
|
this.prisma.activity.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
...assigneeWhere(actor),
|
|
...(personId ? { personId } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
type: true,
|
|
status: true,
|
|
title: true,
|
|
description: true,
|
|
dueAt: true,
|
|
person: { select: { firstName: true, lastName: true } },
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: personId ? 15 : 10,
|
|
}),
|
|
this.prisma.deal.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
...(personId ? { personId } : { status: 'open' }),
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
amount: true,
|
|
currency: true,
|
|
status: true,
|
|
isStale: true,
|
|
isRotten: true,
|
|
person: { select: { firstName: true, lastName: true } },
|
|
stage: { select: { name: true } },
|
|
},
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 10,
|
|
}),
|
|
personId
|
|
? this.prisma.memoryItem.findMany({
|
|
where: { organizationId: actor.organizationId, personId },
|
|
select: { id: true, content: true, updatedAt: true },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 8,
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
const catalog: CatalogItem[] = [];
|
|
const field =
|
|
org?.businessField && isBusinessField(org.businessField)
|
|
? BUSINESS_FIELD_LABELS[org.businessField]
|
|
: 'نامشخص';
|
|
const envLines = memberships.map((row) => {
|
|
const current = row.organizationId === actor.organizationId ? ' [current]' : '';
|
|
return `${row.organization.name}${current} (role=${roleLabel(row.role)})`;
|
|
});
|
|
const billingDays = ownerBilling
|
|
? Math.max(
|
|
0,
|
|
Math.ceil(
|
|
(ownerBilling.expiresAt.getTime() - now.getTime()) /
|
|
(24 * 60 * 60 * 1000),
|
|
),
|
|
)
|
|
: null;
|
|
const lines: string[] = [
|
|
`Today: ${now.toISOString()}`,
|
|
'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}`,
|
|
];
|
|
if (ownerBilling && billingDays != null) {
|
|
lines.push(
|
|
`Subscription of current environment owner: ${planLabel(ownerBilling.planCode)} | status=${ownerBilling.status} | days remaining=${billingDays} | max environments=${ownerBilling.maxOrganizations}`,
|
|
);
|
|
}
|
|
lines.push(
|
|
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 },
|
|
include: { company: { select: { name: true } } },
|
|
});
|
|
if (person) {
|
|
catalog.push({
|
|
type: 'person',
|
|
id: person.id,
|
|
label: `${person.firstName} ${person.lastName}`.trim(),
|
|
});
|
|
lines.push(
|
|
`Person: ${person.firstName} ${person.lastName} | position: ${person.position ?? '—'} | company: ${person.company?.name ?? '—'} | mood: ${person.mood} | phone: ${person.mobile || person.phone || '—'} | email: ${person.email ?? '—'}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const item of memories) {
|
|
catalog.push({ type: 'memory', id: item.id, label: clip(item.content, 60) });
|
|
lines.push(`Memory ${item.id}: ${clip(item.content, 220)}`);
|
|
}
|
|
for (const note of notes) {
|
|
const who = note.person
|
|
? `${note.person.firstName} ${note.person.lastName}`
|
|
: '';
|
|
catalog.push({
|
|
type: 'note',
|
|
id: note.id,
|
|
label: clip(note.title || note.body, 60),
|
|
});
|
|
lines.push(
|
|
`Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
|
|
);
|
|
}
|
|
for (const activity of activities) {
|
|
const who = activity.person
|
|
? `${activity.person.firstName} ${activity.person.lastName}`
|
|
: '';
|
|
catalog.push({ type: 'activity', id: activity.id, label: activity.title });
|
|
lines.push(
|
|
`Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
|
|
);
|
|
}
|
|
for (const deal of deals) {
|
|
const who = deal.person
|
|
? `${deal.person.firstName} ${deal.person.lastName}`
|
|
: '';
|
|
catalog.push({ type: 'deal', id: deal.id, label: deal.title });
|
|
const flags = [
|
|
deal.isStale ? 'stale' : null,
|
|
deal.isRotten ? 'rotten' : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(',');
|
|
lines.push(
|
|
`Deal ${deal.id} ${deal.title} status=${deal.status} stage=${deal.stage.name} amount=${deal.amount != null ? String(deal.amount) : '—'} ${deal.currency}${flags ? ` flags=${flags}` : ''}${who ? ` person=${who}` : ''}`,
|
|
);
|
|
}
|
|
|
|
return { context: lines.join('\n').slice(0, 12000), catalog };
|
|
}
|
|
}
|