346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
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;
|
|||
|
|
}
|