Files
CRM_Montzar/apps/api/src/behavior/copilot.service.ts
T
MahdiandCursor 002f697dfe feat: add DISC profiles and a sales copilot as an org module
Help reps capture a customer's likely behavior style and get negotiation guidance, with the feature gated from super admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:04:26 +03:30

346 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
analyzeCopilot,
isDiscType,
type CopilotAnalysis,
type CopilotFacts,
type SalesCopilotSessionDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { dealAccessWhere, ownerWhere, scopedOwnerIds, seesAllRecords } from '../common/record-scope';
import { ActivitiesService } from '../activities/activities.service';
import { AiService } from '../ai/ai.service';
import { BehaviorAccessService } from './behavior-access.service';
import type { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto } from './dto';
@Injectable()
export class CopilotService {
constructor(
private readonly prisma: PrismaService,
private readonly access: BehaviorAccessService,
private readonly activities: ActivitiesService,
private readonly ai: AiService,
) {}
async analyze(actor: Actor, dto: AnalyzeCopilotDto): Promise<CopilotAnalysis> {
await this.access.assert(actor);
const facts = await this.facts(actor, dto);
const analysis = analyzeCopilot({
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement,
behaviorNotes: dto.behaviorNotes,
facts,
});
return this.polish(analysis, facts, dto.customerStatement);
}
async list(
actor: Actor,
filter: { personId?: string; leadId?: string; dealId?: string },
): Promise<SalesCopilotSessionDto[]> {
await this.access.assert(actor);
if (!filter.personId && !filter.leadId && !filter.dealId) {
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
}
const rows = await this.prisma.salesCopilotSession.findMany({
where: {
organizationId: actor.organizationId,
...(filter.personId ? { personId: filter.personId } : {}),
...(filter.leadId ? { leadId: filter.leadId } : {}),
...(filter.dealId ? { dealId: filter.dealId } : {}),
},
include: { user: { select: { fullName: true } } },
orderBy: { createdAt: 'desc' },
take: 30,
});
return rows.map((row) => this.toDto(row));
}
async save(actor: Actor, dto: SaveCopilotDto): Promise<SalesCopilotSessionDto> {
await this.access.assert(actor);
const facts = await this.facts(actor, dto);
const analysis = await this.polish(
analyzeCopilot({
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement,
behaviorNotes: dto.behaviorNotes,
facts,
}),
facts,
dto.customerStatement,
);
const row = await this.prisma.salesCopilotSession.create({
data: {
organizationId: actor.organizationId,
personId: facts.personId,
leadId: facts.leadId,
dealId: facts.dealId,
userId: actor.userId,
topic: dto.topic,
goal: dto.goal,
customerStatement: dto.customerStatement.trim(),
behaviorNotes: dto.behaviorNotes?.trim() || null,
analysis: analysis as unknown as Prisma.InputJsonValue,
confirmedResponse: dto.confirmedResponse?.trim() || null,
nextAction: dto.nextAction?.trim() || analysis.next_best_action,
followUpAt: dto.followUpAt ? new Date(dto.followUpAt) : null,
callOutcome: dto.callOutcome?.trim() || null,
agentNote: dto.agentNote?.trim() || null,
status: dto.status,
messageDraft: dto.messageDraft?.trim() || null,
},
include: { user: { select: { fullName: true } } },
});
return this.toDto(row);
}
async createFollowUp(actor: Actor, sessionId: string, dto: CopilotFollowUpDto) {
await this.access.assert(actor);
const session = await this.prisma.salesCopilotSession.findFirst({
where: { id: sessionId, organizationId: actor.organizationId, status: 'confirmed' },
});
if (!session) throw new NotFoundException('نتیجهٔ تأییدشده پیدا نشد');
if (!session.personId && !session.dealId && !session.leadId) {
throw new BadRequestException('این نتیجه به پرونده‌ای وصل نیست');
}
const due = dto.dueAt || (session.followUpAt ? session.followUpAt.toISOString() : null);
const activity = await this.activities.create(
actor.organizationId,
actor.userId,
{
title: session.nextAction?.trim() || 'پیگیری مذاکره',
type: 'task',
description: session.confirmedResponse || session.customerStatement,
dueAt: due,
personId: session.personId,
dealId: session.dealId,
assigneeId: dto.assigneeId || actor.userId,
},
actor,
);
await this.prisma.salesCopilotSession.update({
where: { id: session.id },
data: { activityId: activity.id },
});
return { activityId: activity.id };
}
private async facts(
actor: Actor,
dto: { personId?: string; leadId?: string; dealId?: string },
): Promise<CopilotFacts & { personId: string | null; leadId: string | null; dealId: string | null }> {
if (!dto.personId && !dto.leadId && !dto.dealId) {
throw new BadRequestException('پرونده، سرنخ یا معامله را مشخص کنید');
}
const deal = dto.dealId
? await this.prisma.deal.findFirst({
where: { id: dto.dealId, organizationId: actor.organizationId, ...dealAccessWhere(actor) },
select: {
id: true,
title: true,
amount: true,
currency: true,
personId: true,
stage: { select: { name: true } },
},
})
: null;
if (dto.dealId && !deal) throw new NotFoundException('معامله پیدا نشد');
const lead = dto.leadId
? await this.prisma.lead.findFirst({
where: { id: dto.leadId, organizationId: actor.organizationId },
select: {
id: true,
title: true,
trade: true,
estimatedValue: true,
convertedPersonId: true,
assigneeId: true,
stage: { select: { name: true } },
},
})
: null;
if (dto.leadId && !lead) throw new NotFoundException('سرنخ پیدا نشد');
if (lead && !seesAllRecords(actor, 'leads')) {
const ids = scopedOwnerIds(actor, 'leads') ?? [actor.userId];
if (lead.assigneeId && !ids.includes(lead.assigneeId)) {
throw new NotFoundException('سرنخ پیدا نشد');
}
}
const personId = dto.personId || deal?.personId || lead?.convertedPersonId || null;
const person = personId
? await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: {
id: true,
firstName: true,
lastName: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
company: { select: { name: true } },
},
})
: null;
if (personId && !person) throw new NotFoundException('شخص پیدا نشد');
const recent = person
? await this.prisma.activity.findMany({
where: { organizationId: actor.organizationId, personId: person.id },
orderBy: { createdAt: 'desc' },
take: 3,
select: { title: true },
})
: [];
const price = deal?.amount
? `${deal.amount.toString()} ${deal.currency}`
: lead?.estimatedValue
? `${lead.estimatedValue.toString()}`
: null;
const primaryTypes = (person?.discLabel ?? '')
.split('/')
.map((part) => part.trim())
.filter(isDiscType);
return {
personId: person?.id ?? null,
leadId: lead?.id ?? null,
dealId: deal?.id ?? null,
customerName: person ? `${person.firstName} ${person.lastName}`.trim() : lead?.title ?? null,
businessType: person?.company?.name || lead?.trade || null,
productName: deal?.title || lead?.title || null,
stageName: deal?.stage.name || lead?.stage.name || null,
priceOffered: price,
lastAction: recent.map((row) => row.title).join('؛ ') || null,
disc:
person && person.discLabel
? {
label: person.discLabel,
primaryTypes,
reliable: person.discReliable,
assessedAt: person.discAssessedAt?.toISOString() ?? null,
}
: null,
};
}
private async polish(
analysis: CopilotAnalysis,
facts: CopilotFacts,
statement: string,
): Promise<CopilotAnalysis> {
if (!this.ai.isEnabled() || analysis.needs_more_input || !analysis.suggested_response) {
return analysis;
}
const factText = [
statement,
facts.customerName,
facts.businessType,
facts.productName,
facts.stageName,
facts.priceOffered,
facts.lastAction,
analysis.objection_label,
]
.filter(Boolean)
.join('\n');
try {
const raw = await this.ai.completeJson<{
recommended_question?: string;
suggested_response?: string;
}>(
`تو مربی فروش فارسی‌زبان هستی. فقط JSON برگردان:
{"recommended_question": string, "suggested_response": string}
قواعد:
- چیزی را که در اطلاعات داده نشده نساز؛ به‌خصوص قیمت، تخفیف، قابلیت محصول و تعهد پشتیبانی.
- اگر عددی در اطلاعات نیست، عدد جدید ننویس.
- DISC را تشخیص قطعی شخصیت ندان.
- پاسخ کوتاه، محترمانه و قابل گفتن پای تلفن باشد.`,
factText,
);
const question = keepGrounded(raw.recommended_question, factText);
const response = keepGrounded(raw.suggested_response, factText);
if (!question && !response) return analysis;
return {
...analysis,
recommended_question: question || analysis.recommended_question,
suggested_response: response || analysis.suggested_response,
source: 'rules+ai',
confidence_note: `${analysis.confidence_note} متن با مدل زبانی بازنویسی شد و هنوز باید توسط کارشناس تأیید شود.`,
};
} catch {
return analysis;
}
}
private toDto(row: {
id: string;
personId: string | null;
leadId: string | null;
dealId: string | null;
userId: string;
user: { fullName: string };
topic: string;
goal: string;
customerStatement: string;
behaviorNotes: string | null;
analysis: unknown;
confirmedResponse: string | null;
nextAction: string | null;
followUpAt: Date | null;
callOutcome: string | null;
agentNote: string | null;
status: string;
messageDraft: string | null;
activityId: string | null;
createdAt: Date;
updatedAt: Date;
}): SalesCopilotSessionDto {
return {
id: row.id,
personId: row.personId,
leadId: row.leadId,
dealId: row.dealId,
userId: row.userId,
userName: row.user.fullName,
topic: row.topic as SalesCopilotSessionDto['topic'],
goal: row.goal as SalesCopilotSessionDto['goal'],
customerStatement: row.customerStatement,
behaviorNotes: row.behaviorNotes,
analysis: row.analysis as CopilotAnalysis,
confirmedResponse: row.confirmedResponse,
nextAction: row.nextAction,
followUpAt: row.followUpAt?.toISOString() ?? null,
callOutcome: row.callOutcome,
agentNote: row.agentNote,
status: row.status === 'dismissed' ? 'dismissed' : 'confirmed',
messageDraft: row.messageDraft,
activityId: row.activityId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function keepGrounded(text: string | undefined, facts: string): string {
const trimmed = text?.trim() ?? '';
if (!trimmed || trimmed.length > 900) return '';
const numbers = trimmed.match(/\d[\d۰-۹,،.]*/g) ?? [];
for (const token of numbers) {
const digits = token.replace(/\D/g, '');
if (digits.length >= 2 && !facts.includes(digits)) return '';
}
return trimmed;
}