Files
CRM_Montzar/apps/api/src/ai/ai-ask.service.ts
T

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 };
}
}