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