diff --git a/apps/api/package.json b/apps/api/package.json index f50a5af..aea76fe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "prisma:seed": "tsx prisma/seed.ts", "lint": "echo \"api lint placeholder\"", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", - "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts" + "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts test/organizations.test.ts test/promote-from-prior.test.ts test/behavior.test.ts" }, "dependencies": { "@mento/shared": "*", diff --git a/apps/api/prisma/migrations/20261018180000_behavior_disc_copilot/migration.sql b/apps/api/prisma/migrations/20261018180000_behavior_disc_copilot/migration.sql new file mode 100644 index 0000000..2954ae2 --- /dev/null +++ b/apps/api/prisma/migrations/20261018180000_behavior_disc_copilot/migration.sql @@ -0,0 +1,97 @@ +-- AlterTable +ALTER TABLE "Person" ADD COLUMN "discLabel" TEXT, +ADD COLUMN "discReliable" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "discAssessedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "Person_organizationId_discLabel_idx" ON "Person"("organizationId", "discLabel"); + +-- CreateTable +CREATE TABLE "DiscAssessment" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "personId" TEXT NOT NULL, + "assessorId" TEXT NOT NULL, + "answers" JSONB NOT NULL, + "scoreD" INTEGER NOT NULL, + "scoreI" INTEGER NOT NULL, + "scoreS" INTEGER NOT NULL, + "scoreC" INTEGER NOT NULL, + "percentD" INTEGER NOT NULL, + "percentI" INTEGER NOT NULL, + "percentS" INTEGER NOT NULL, + "percentC" INTEGER NOT NULL, + "validCount" INTEGER NOT NULL, + "primaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[], + "secondaryTypes" TEXT[] DEFAULT ARRAY[]::TEXT[], + "label" TEXT NOT NULL, + "secondaryLabel" TEXT, + "reliable" BOOLEAN NOT NULL, + "confidence" TEXT NOT NULL, + "note" TEXT, + "status" TEXT NOT NULL DEFAULT 'confirmed', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DiscAssessment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SalesCopilotSession" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "personId" TEXT, + "leadId" TEXT, + "dealId" TEXT, + "userId" TEXT NOT NULL, + "topic" TEXT NOT NULL, + "goal" TEXT NOT NULL, + "customerStatement" TEXT NOT NULL, + "behaviorNotes" TEXT, + "analysis" JSONB NOT NULL, + "confirmedResponse" TEXT, + "nextAction" TEXT, + "followUpAt" TIMESTAMP(3), + "callOutcome" TEXT, + "agentNote" TEXT, + "status" TEXT NOT NULL, + "messageDraft" TEXT, + "activityId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SalesCopilotSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DiscAssessment_organizationId_personId_createdAt_idx" ON "DiscAssessment"("organizationId", "personId", "createdAt"); +CREATE INDEX "DiscAssessment_personId_idx" ON "DiscAssessment"("personId"); +CREATE INDEX "SalesCopilotSession_organizationId_personId_createdAt_idx" ON "SalesCopilotSession"("organizationId", "personId", "createdAt"); +CREATE INDEX "SalesCopilotSession_organizationId_leadId_idx" ON "SalesCopilotSession"("organizationId", "leadId"); +CREATE INDEX "SalesCopilotSession_dealId_idx" ON "SalesCopilotSession"("dealId"); + +-- AddForeignKey +ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DiscAssessment" ADD CONSTRAINT "DiscAssessment_assessorId_fkey" FOREIGN KEY ("assessorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SalesCopilotSession" ADD CONSTRAINT "SalesCopilotSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ماژول برای محیط‌هایی که از قبل ماژول دارند روشن می‌شود و در سوپرادمین قابل خاموش کردن است +UPDATE "Organization" +SET "enabledModules" = array_append("enabledModules", 'behavior') +WHERE cardinality("enabledModules") > 0 + AND NOT ('behavior' = ANY("enabledModules")); + +-- مجوز رفتار برای نقش‌هایی که فروش دارند؛ پشتیبانی بدون این کلید می‌ماند +UPDATE "OrgRole" +SET "permissions" = array_append("permissions", 'behavior') +WHERE NOT ('behavior' = ANY("permissions")) + AND ( + "systemKey" IN ('owner', 'admin', 'sales') + OR 'sales' = ANY("permissions") + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b2bdf5e..f13637e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -161,6 +161,8 @@ model Organization { teams Team[] companies Company[] people Person[] + discAssessments DiscAssessment[] + salesCopilotSessions SalesCopilotSession[] pipelines Pipeline[] deals Deal[] activities Activity[] @@ -311,6 +313,8 @@ model User { routineTaskEvents RoutineTaskEvent[] routineSettings RoutineUserSettings[] deletionArchives DeletionArchive[] @relation("DeletionActor") + discAssessments DiscAssessment[] @relation("DiscAssessor") + salesCopilotSessions SalesCopilotSession[] @relation("CopilotAuthor") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -554,6 +558,11 @@ model Person { routineTasks RoutineTask[] outboundSmsLogs OutboundSmsLog[] financialDocuments FinancialDocument[] + discLabel String? + discReliable Boolean @default(false) + discAssessedAt DateTime? + discAssessments DiscAssessment[] + salesCopilotSessions SalesCopilotSession[] financialReceivables FinancialReceivable[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -569,6 +578,7 @@ model Person { @@index([companyId]) @@index([ownerId]) @@index([loyaltyTierId]) + @@index([organizationId, discLabel]) } model Pipeline { @@ -640,6 +650,7 @@ model Deal { expectedCloseAt DateTime? personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + salesCopilotSessions SalesCopilotSession[] companyId String? company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) ownerId String? @@ -2766,6 +2777,7 @@ model Lead { convertedAt DateTime? archivedAt DateTime? events LeadEvent[] + salesCopilotSessions SalesCopilotSession[] trackingLinks TrackingLink[] trackingEvents TrackingEvent[] routineTasks RoutineTask[] @@ -3337,3 +3349,71 @@ model GamificationBonus { @@index([organizationId, isActive]) } + +/// ارزیابی رفتاری DISC؛ هر بار یک رکورد تازه است و تاریخچه پاک نمی‌شود. +model DiscAssessment { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + personId String + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + assessorId String + assessor User @relation("DiscAssessor", fields: [assessorId], references: [id], onDelete: Restrict) + answers Json + scoreD Int + scoreI Int + scoreS Int + scoreC Int + percentD Int + percentI Int + percentS Int + percentC Int + validCount Int + primaryTypes String[] @default([]) + secondaryTypes String[] @default([]) + label String + secondaryLabel String? + reliable Boolean + confidence String + note String? + status String @default("confirmed") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, personId, createdAt]) + @@index([personId]) +} + +/// نتیجهٔ تأییدشدهٔ دستیار فروش. پیشنهاد خام بدون تأیید کارشناس اینجا ذخیره نمی‌شود. +model SalesCopilotSession { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: Cascade) + leadId String? + lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + userId String + user User @relation("CopilotAuthor", fields: [userId], references: [id], onDelete: Restrict) + topic String + goal String + customerStatement String + behaviorNotes String? + analysis Json + confirmedResponse String? + nextAction String? + followUpAt DateTime? + callOutcome String? + agentNote String? + status String + messageDraft String? + activityId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, personId, createdAt]) + @@index([organizationId, leadId]) + @@index([dealId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cd4d9e9..f6ca709 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -52,6 +52,7 @@ import { RoutinesModule } from './routines/routines.module'; import { GamificationModule } from './gamification/gamification.module'; import { DeletionsModule } from './deletions/deletions.module'; import { OutboundSmsModule } from './outbound-sms/outbound-sms.module'; +import { BehaviorModule } from './behavior/behavior.module'; @Module({ imports: [ @@ -111,6 +112,7 @@ import { OutboundSmsModule } from './outbound-sms/outbound-sms.module'; RoutinesModule, GamificationModule, DeletionsModule, + BehaviorModule, ], }) export class AppModule {} diff --git a/apps/api/src/behavior/behavior-access.service.ts b/apps/api/src/behavior/behavior-access.service.ts new file mode 100644 index 0000000..82b1430 --- /dev/null +++ b/apps/api/src/behavior/behavior-access.service.ts @@ -0,0 +1,39 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import type { Actor } from '../common/record-scope'; +import { BillingService } from '../billing/billing.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class BehaviorAccessService { + constructor( + private readonly prisma: PrismaService, + private readonly billing: BillingService, + ) {} + + async canView(actor: Actor): Promise { + const org = await this.prisma.organization.findUnique({ + where: { id: actor.organizationId }, + select: { enabledModules: true }, + }); + if (!this.billing.normalizeModules(org?.enabledModules).includes('behavior')) { + return false; + } + if (actor.role === 'owner' || actor.role === 'admin') return true; + const membership = await this.prisma.membership.findFirst({ + where: { + userId: actor.userId, + organizationId: actor.organizationId, + isActive: true, + }, + select: { orgRole: { select: { permissions: true } } }, + }); + const permissions = membership?.orgRole?.permissions ?? []; + return permissions.includes('behavior') || permissions.includes('sales'); + } + + async assert(actor: Actor): Promise { + if (!(await this.canView(actor))) { + throw new ForbiddenException('دسترسی به تحلیل رفتاری و دستیار فروش ندارید'); + } + } +} diff --git a/apps/api/src/behavior/behavior.controller.ts b/apps/api/src/behavior/behavior.controller.ts new file mode 100644 index 0000000..3deaaf5 --- /dev/null +++ b/apps/api/src/behavior/behavior.controller.ts @@ -0,0 +1,85 @@ +import { + Body, + Controller, + Get, + Header, + Param, + Post, + Query, + StreamableFile, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { RequireOrgModule } from '../billing/org-module.decorator'; +import { DiscService } from './disc.service'; +import { CopilotService } from './copilot.service'; +import { AnalyzeCopilotDto, CopilotFollowUpDto, SaveCopilotDto, SaveDiscDto } from './dto'; + +@Controller('behavior') +@UseGuards(JwtAuthGuard, OrgModuleGuard) +@RequireOrgModule('behavior') +export class BehaviorController { + constructor( + private readonly disc: DiscService, + private readonly copilot: CopilotService, + ) {} + + @Get('disc/distribution') + distribution(@CurrentUser() user: Actor) { + return this.disc.distribution(user); + } + + @Get('disc/export') + @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + @Header('Content-Disposition', 'attachment; filename="disc.xlsx"') + async exportDisc(@CurrentUser() user: Actor) { + const buf = await this.disc.exportXlsx(user); + return new StreamableFile(buf); + } + + @Get('people/:personId/disc') + listDisc(@CurrentUser() user: Actor, @Param('personId') personId: string) { + return this.disc.list(user, personId); + } + + @Post('people/:personId/disc') + saveDisc( + @CurrentUser() user: Actor, + @Param('personId') personId: string, + @Body() dto: SaveDiscDto, + ) { + return this.disc.save(user, personId, dto); + } + + @Post('copilot/analyze') + analyze(@CurrentUser() user: Actor, @Body() dto: AnalyzeCopilotDto) { + return this.copilot.analyze(user, dto); + } + + @Get('copilot/sessions') + sessions( + @CurrentUser() user: Actor, + @Query('personId') personId?: string, + @Query('leadId') leadId?: string, + @Query('dealId') dealId?: string, + ) { + return this.copilot.list(user, { personId, leadId, dealId }); + } + + @Post('copilot/sessions') + saveSession(@CurrentUser() user: Actor, @Body() dto: SaveCopilotDto) { + return this.copilot.save(user, dto); + } + + @Post('copilot/sessions/:id/follow-up') + followUp( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: CopilotFollowUpDto, + ) { + return this.copilot.createFollowUp(user, id, dto); + } +} diff --git a/apps/api/src/behavior/behavior.module.ts b/apps/api/src/behavior/behavior.module.ts new file mode 100644 index 0000000..b6a928a --- /dev/null +++ b/apps/api/src/behavior/behavior.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ActivitiesModule } from '../activities/activities.module'; +import { BehaviorAccessService } from './behavior-access.service'; +import { BehaviorController } from './behavior.controller'; +import { CopilotService } from './copilot.service'; +import { DiscService } from './disc.service'; + +@Module({ + imports: [ActivitiesModule], + controllers: [BehaviorController], + providers: [BehaviorAccessService, DiscService, CopilotService], + exports: [BehaviorAccessService], +}) +export class BehaviorModule {} diff --git a/apps/api/src/behavior/copilot.service.ts b/apps/api/src/behavior/copilot.service.ts new file mode 100644 index 0000000..828f630 --- /dev/null +++ b/apps/api/src/behavior/copilot.service.ts @@ -0,0 +1,345 @@ +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; +} diff --git a/apps/api/src/behavior/disc.service.ts b/apps/api/src/behavior/disc.service.ts new file mode 100644 index 0000000..fc20bd0 --- /dev/null +++ b/apps/api/src/behavior/disc.service.ts @@ -0,0 +1,242 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + type DiscAnswers, + type DiscAssessmentDto, + type DiscDistributionRow, + type DiscType, + isDiscType, + scoreDisc, +} from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { Actor } from '../common/record-scope'; +import { ownerWhere } from '../common/record-scope'; +import { writeXlsx } from '../common/excel'; +import { BehaviorAccessService } from './behavior-access.service'; +import type { SaveDiscDto } from './dto'; + +function asAnswers(value: unknown): DiscAnswers { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const out: DiscAnswers = {}; + for (const [key, raw] of Object.entries(value as Record)) { + if (raw == null || raw === '') out[key] = null; + else if (isDiscType(raw)) out[key] = raw; + } + return out; +} + +function asTypes(values: string[]): DiscType[] { + return values.filter(isDiscType); +} + +@Injectable() +export class DiscService { + constructor( + private readonly prisma: PrismaService, + private readonly access: BehaviorAccessService, + ) {} + + async list(actor: Actor, personId: string): Promise { + await this.access.assert(actor); + await this.ensurePerson(actor, personId); + const rows = await this.prisma.discAssessment.findMany({ + where: { organizationId: actor.organizationId, personId }, + include: { assessor: { select: { id: true, fullName: true } } }, + orderBy: { createdAt: 'desc' }, + }); + return rows.map((row) => this.toDto(row)); + } + + async save(actor: Actor, personId: string, dto: SaveDiscDto): Promise { + await this.access.assert(actor); + await this.ensurePerson(actor, personId); + const answers = asAnswers(dto.answers); + const scored = scoreDisc(answers); + if (scored.validCount === 0) { + throw new BadRequestException('حداقل یک پاسخ معتبر لازم است'); + } + const note = dto.note?.trim() || null; + const row = await this.prisma.discAssessment.create({ + data: { + organizationId: actor.organizationId, + personId, + assessorId: actor.userId, + answers: answers as Prisma.InputJsonValue, + scoreD: scored.scores.D, + scoreI: scored.scores.I, + scoreS: scored.scores.S, + scoreC: scored.scores.C, + percentD: scored.percents.D, + percentI: scored.percents.I, + percentS: scored.percents.S, + percentC: scored.percents.C, + validCount: scored.validCount, + primaryTypes: scored.primaryTypes, + secondaryTypes: scored.secondaryTypes, + label: scored.label, + secondaryLabel: scored.secondaryLabel, + reliable: scored.reliable, + confidence: scored.confidence, + note, + status: 'confirmed', + }, + include: { assessor: { select: { id: true, fullName: true } } }, + }); + if (scored.reliable) { + await this.prisma.person.update({ + where: { id: personId }, + data: { + discLabel: scored.label, + discReliable: true, + discAssessedAt: row.createdAt, + }, + }); + } + return this.toDto(row); + } + + async distribution(actor: Actor): Promise { + await this.access.assert(actor); + const rows = await this.prisma.person.groupBy({ + by: ['discLabel'], + where: { + organizationId: actor.organizationId, + discReliable: true, + discLabel: { not: null }, + ...ownerWhere(actor), + }, + _count: { _all: true }, + }); + return rows + .flatMap((row) => + row.discLabel + ? [{ label: row.discLabel, count: row._count._all }] + : [], + ) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); + } + + async exportXlsx(actor: Actor): Promise { + await this.access.assert(actor); + const people = await this.prisma.person.findMany({ + where: { + organizationId: actor.organizationId, + discReliable: true, + ...ownerWhere(actor), + }, + select: { id: true, firstName: true, lastName: true, mobile: true, discLabel: true }, + orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], + }); + const latest = await this.prisma.discAssessment.findMany({ + where: { + organizationId: actor.organizationId, + personId: { in: people.map((p) => p.id) }, + reliable: true, + }, + include: { assessor: { select: { fullName: true } } }, + orderBy: { createdAt: 'desc' }, + }); + const byPerson = new Map(); + for (const row of latest) { + if (!byPerson.has(row.personId)) byPerson.set(row.personId, row); + } + return writeXlsx( + 'DISC', + [ + { header: 'مشتری', key: 'name', width: 28 }, + { header: 'موبایل', key: 'mobile', width: 16 }, + { header: 'تیپ غالب', key: 'label', width: 16 }, + { header: 'تیپ دوم', key: 'secondary', width: 16 }, + { header: 'D', key: 'd', width: 8 }, + { header: 'I', key: 'i', width: 8 }, + { header: 'S', key: 's', width: 8 }, + { header: 'C', key: 'c', width: 8 }, + { header: 'اطمینان', key: 'confidence', width: 14 }, + { header: 'کارشناس', key: 'assessor', width: 22 }, + { header: 'تاریخ', key: 'at', width: 22 }, + { header: 'یادداشت', key: 'note', width: 40 }, + ], + people.map((person) => { + const row = byPerson.get(person.id); + return { + name: `${person.firstName} ${person.lastName}`.trim(), + mobile: person.mobile, + label: person.discLabel, + secondary: row?.secondaryLabel, + d: row?.scoreD, + i: row?.scoreI, + s: row?.scoreS, + c: row?.scoreC, + confidence: row?.confidence, + assessor: row?.assessor.fullName, + at: row?.createdAt.toISOString(), + note: row?.note, + }; + }), + ); + } + + private async ensurePerson(actor: Actor, personId: string) { + const found = await this.prisma.person.findFirst({ + where: { id: personId, organizationId: actor.organizationId, ...ownerWhere(actor) }, + select: { id: true }, + }); + if (!found) throw new NotFoundException('شخص پیدا نشد'); + } + + private toDto(row: { + id: string; + personId: string; + assessorId: string; + assessor: { fullName: string }; + answers: unknown; + scoreD: number; + scoreI: number; + scoreS: number; + scoreC: number; + percentD: number; + percentI: number; + percentS: number; + percentC: number; + validCount: number; + primaryTypes: string[]; + secondaryTypes: string[]; + label: string; + secondaryLabel: string | null; + reliable: boolean; + confidence: string; + note: string | null; + createdAt: Date; + updatedAt: Date; + }): DiscAssessmentDto { + const confidence = (['insufficient', 'low', 'medium', 'high'] as const).includes( + row.confidence as 'insufficient', + ) + ? (row.confidence as DiscAssessmentDto['confidence']) + : 'low'; + return { + id: row.id, + personId: row.personId, + assessorId: row.assessorId, + assessorName: row.assessor.fullName, + answers: asAnswers(row.answers), + scores: { D: row.scoreD, I: row.scoreI, S: row.scoreS, C: row.scoreC }, + percents: { D: row.percentD, I: row.percentI, S: row.percentS, C: row.percentC }, + validCount: row.validCount, + primaryTypes: asTypes(row.primaryTypes), + secondaryTypes: asTypes(row.secondaryTypes), + label: row.label, + secondaryLabel: row.secondaryLabel, + reliable: row.reliable, + confidence, + note: row.note, + status: 'confirmed', + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/apps/api/src/behavior/dto.ts b/apps/api/src/behavior/dto.ts new file mode 100644 index 0000000..562716c --- /dev/null +++ b/apps/api/src/behavior/dto.ts @@ -0,0 +1,106 @@ +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsIn, + IsObject, + IsOptional, + IsString, + MaxLength, + ValidateIf, +} from 'class-validator'; +import { + COPILOT_GOALS, + COPILOT_TOPICS, + type CopilotGoal, + type CopilotTopic, +} from '@mento/shared'; + +export class SaveDiscDto { + @IsObject() + answers!: Record; + + @IsOptional() + @IsString() + @MaxLength(4000) + note?: string; +} + +export class AnalyzeCopilotDto { + @IsOptional() + @IsString() + personId?: string; + + @IsOptional() + @IsString() + leadId?: string; + + @IsOptional() + @IsString() + dealId?: string; + + @IsIn([...COPILOT_TOPICS]) + topic!: CopilotTopic; + + @IsIn([...COPILOT_GOALS]) + goal!: CopilotGoal; + + @IsString() + @MaxLength(4000) + customerStatement!: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + behaviorNotes?: string; +} + +export class SaveCopilotDto extends AnalyzeCopilotDto { + @IsIn(['confirmed', 'dismissed']) + status!: 'confirmed' | 'dismissed'; + + @IsOptional() + @IsString() + @MaxLength(4000) + confirmedResponse?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + nextAction?: string; + + @IsOptional() + @ValidateIf((_, value) => value != null && value !== '') + @IsDateString() + followUpAt?: string | null; + + @IsOptional() + @IsString() + @MaxLength(500) + callOutcome?: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + agentNote?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + messageDraft?: string; +} + +export class CopilotFollowUpDto { + @IsOptional() + @IsDateString() + dueAt?: string; + + @IsOptional() + @IsString() + assigneeId?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + createTask?: boolean; +} diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index 0639ec2..d661135 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -9,6 +9,7 @@ import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; import { SmsService } from '../common/sms.service'; +import { BehaviorModule } from '../behavior/behavior.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { SmsService } from '../common/sms.service'; AutomationsModule, MessageAutomationsModule, PublicApiModule, + BehaviorModule, ], controllers: [PeopleController, CompaniesController], providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService], diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index c671225..9e33be2 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -80,6 +80,12 @@ export class ListQueryDto { @IsOptional() @IsIn(['1', 'true', '0', 'false']) hasMobile?: string; + + /** فیلتر تیپ DISC غالب یا ترکیبی، مثل D یا D/C */ + @IsOptional() + @IsString() + @MaxLength(12) + disc?: string; } class CompanyExtraFieldsDto { diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index 869135f..a440b98 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -59,6 +59,7 @@ export class PeopleController { province: query.province, city: query.city, hasMobile, + disc: query.disc, }, ); } @@ -75,6 +76,7 @@ export class PeopleController { province: query.province, city: query.city, hasMobile, + disc: query.disc, }); } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index b0c2036..d029ea3 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -50,6 +50,7 @@ import { normalizePersonImportSource, personImportSourceLabel, } from './person-source'; +import { BehaviorAccessService } from '../behavior/behavior-access.service'; const personInclude = { company: { select: { id: true, name: true, province: true, city: true } }, @@ -98,8 +99,27 @@ type PersonRow = { minPoints: number; color: string | null; } | null; + discLabel: string | null; + discReliable: boolean; + discAssessedAt: Date | null; }; +function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null { + const value = raw?.trim().toUpperCase(); + if (!value || !/^[DISC](\/[DISC]){0,3}$/.test(value)) return null; + if (value.includes('/')) { + return { discReliable: true, discLabel: value }; + } + return { + discReliable: true, + OR: [ + { discLabel: value }, + { discLabel: { startsWith: `${value}/` } }, + { discLabel: { contains: `/${value}` } }, + ], + }; +} + @Injectable() export class PeopleService implements OnModuleInit { private readonly logger = new Logger(PeopleService.name); @@ -113,6 +133,7 @@ export class PeopleService implements OnModuleInit { private readonly game: GamificationService, private readonly sms: SmsService, private readonly outboundSms: OutboundSmsLogService, + private readonly behaviorAccess: BehaviorAccessService, ) {} private personListWhere( @@ -124,6 +145,7 @@ export class PeopleService implements OnModuleInit { province?: string; city?: string; hasMobile?: boolean; + disc?: string; }, ): Prisma.PersonWhereInput { const sourceFilter = opts?.importSource?.trim() @@ -166,6 +188,8 @@ export class PeopleService implements OnModuleInit { if (opts?.hasMobile) { and.push({ mobile: { not: null } }, { NOT: { mobile: '' } }); } + const disc = discFilter(opts?.disc); + if (disc) and.push(disc); return { organizationId: actor.organizationId, ...ownerWhere(actor), @@ -187,9 +211,11 @@ export class PeopleService implements OnModuleInit { province?: string; city?: string; hasMobile?: boolean; + disc?: string; }, ): Promise> { - const where = this.personListWhere(actor, q, opts); + const seeDisc = await this.behaviorAccess.canView(actor); + const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined }); const [rows, total] = await this.prisma.$transaction([ this.prisma.person.findMany({ @@ -206,7 +232,7 @@ export class PeopleService implements OnModuleInit { ]); return { - items: rows.map((row) => this.toDto(row)), + items: rows.map((row) => this.toDto(row, seeDisc)), total, page, limit, @@ -222,10 +248,12 @@ export class PeopleService implements OnModuleInit { province?: string; city?: string; hasMobile?: boolean; + disc?: string; }, max = FILTERED_IDS_MAX, ): Promise { - const where = this.personListWhere(actor, q, opts); + const seeDisc = await this.behaviorAccess.canView(actor); + const where = this.personListWhere(actor, q, seeDisc ? opts : { ...opts, disc: undefined }); const total = await this.prisma.person.count({ where }); if (total > max) { throw new BadRequestException( @@ -330,7 +358,7 @@ export class PeopleService implements OnModuleInit { include: personInclude, }); if (!row) throw new NotFoundException('شخص پیدا نشد'); - return this.toDto(row); + return this.toDto(row, await this.behaviorAccess.canView(actor)); } async create(actor: Actor, dto: CreatePersonRequest): Promise { @@ -407,7 +435,7 @@ export class PeopleService implements OnModuleInit { where: { id: row.id }, include: personInclude, }); - return this.toDto(fresh ?? row); + return this.toDto(fresh ?? row, await this.behaviorAccess.canView(actor)); } async update( @@ -473,7 +501,7 @@ export class PeopleService implements OnModuleInit { email: row.email, companyId: row.companyId, }); - return this.toDto(row); + return this.toDto(row, await this.behaviorAccess.canView(actor)); } async remove(actor: Actor, id: string): Promise { @@ -697,7 +725,7 @@ export class PeopleService implements OnModuleInit { ); } - private toDto(row: PersonRow): PersonDto { + private toDto(row: PersonRow, includeDisc: boolean): PersonDto { const previousFullName = `${row.previousFirstName ?? ''} ${row.previousLastName ?? ''}`.trim(); const importSource = row.importSource || 'manual'; return { @@ -736,6 +764,10 @@ export class PeopleService implements OnModuleInit { customFields: asCustomFields(row.customFields), loyaltyPoints: row.loyaltyPoints ?? 0, loyaltyTier: row.loyaltyTier, + discLabel: includeDisc && row.discReliable ? row.discLabel : null, + discReliable: includeDisc ? row.discReliable : false, + discAssessedAt: + includeDisc && row.discAssessedAt ? row.discAssessedAt.toISOString() : null, company: row.company, owner: row.owner, createdAt: row.createdAt.toISOString(), diff --git a/apps/api/test/behavior.test.ts b/apps/api/test/behavior.test.ts new file mode 100644 index 0000000..1760ead --- /dev/null +++ b/apps/api/test/behavior.test.ts @@ -0,0 +1,104 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + analyzeCopilot, + followUpMessage, + scoreDisc, + type DiscAnswers, +} from '../../../packages/shared/src/behavior'; + +function all(type: DiscAnswers[string]): DiscAnswers { + return { '1': type, '2': type, '3': type, '4': type, '5': type, '6': type, '7': type, '8': type }; +} + +describe('امتیاز DISC', () => { + it('گزینه D فقط امتیاز D را زیاد می‌کند', () => { + const result = scoreDisc(all('D')); + assert.equal(result.scores.D, 8); + assert.equal(result.scores.I, 0); + assert.equal(result.scores.S, 0); + assert.equal(result.scores.C, 0); + assert.equal(result.label, 'D'); + assert.equal(result.percents.D, 100); + }); + + it('هر چهار تیپ را جدا جمع می‌کند', () => { + const result = scoreDisc({ '1': 'D', '2': 'I', '3': 'S', '4': 'C', '5': 'D', '6': 'I' }); + assert.deepEqual(result.scores, { D: 2, I: 2, S: 1, C: 1 }); + assert.equal(result.validCount, 6); + assert.equal(result.label, 'D/I'); + }); + + it('در تساوی نتیجه ترکیبی می‌دهد', () => { + const result = scoreDisc({ '1': 'S', '2': 'C', '3': 'S', '4': 'C' }); + assert.equal(result.reliable, true); + assert.equal(result.label, 'S/C'); + }); + + it('با کمتر از چهار پاسخ نتیجه قابل اتکا نمی‌دهد', () => { + const result = scoreDisc({ '1': 'D', '2': 'D', '3': 'D' }); + assert.equal(result.reliable, false); + assert.equal(result.label, 'اطلاعات ناکافی برای نتیجه قابل اتکا'); + assert.deepEqual(result.primaryTypes, []); + }); +}); + +describe('دستیار فروش', () => { + it('برای نیاز ندارم سؤال کشف نیاز می‌دهد و قیمت نمی‌سازد', () => { + const analysis = analyzeCopilot({ + topic: 'objection', + goal: 'discover_need', + customerStatement: 'فعلاً نیازی ندارم؛ همین روش فعلی برای من جواب می‌دهد.', + facts: { customerName: 'مهدی', stageName: 'سرنخ جدید' }, + }); + assert.equal(analysis.objection_category, 'no_need'); + assert.match(analysis.recommended_question, /روش فعلی/); + assert.doesNotMatch(analysis.suggested_response, /\d{3,}/); + assert.equal(analysis.disc_context, null); + }); + + it('DISC تازه را راهنما می‌کند نه تشخیص قطعی', () => { + const analysis = analyzeCopilot({ + topic: 'pricing', + goal: 'follow_price', + customerStatement: 'قیمتتان بالاست.', + facts: { + customerName: 'مهدی', + priceOffered: null, + disc: { + label: 'D', + primaryTypes: ['D'], + reliable: true, + assessedAt: new Date().toISOString(), + }, + }, + }); + assert.equal(analysis.disc_context?.used, true); + assert.match(analysis.suggested_response, /کوتاه می‌گویم/); + assert.match(analysis.confidence_note, /قطعی/); + assert.match(analysis.suggested_response, /عدد یا تخفیف جدیدی پیشنهاد نمی‌شود/); + }); + + it('حرف کوتاه را قطعی تحلیل نمی‌کند', () => { + const analysis = analyzeCopilot({ + topic: 'intro', + goal: 'discover_need', + customerStatement: 'نه', + facts: {}, + }); + assert.equal(analysis.needs_more_input, true); + assert.equal(analysis.suggested_response, ''); + }); + + it('متن پیگیری قیمت را از خودش نمی‌سازد', () => { + const analysis = analyzeCopilot({ + topic: 'pricing', + goal: 'follow_price', + customerStatement: 'باید فکر کنم.', + facts: { customerName: 'سارا', productName: 'منتکت' }, + }); + const text = followUpMessage('price_followup', { customerName: 'سارا', productName: 'منتکت' }, analysis); + assert.match(text, /منتکت/); + assert.match(text, /عدد قیمت را اینجا تکرار نمی‌کنم/); + }); +}); diff --git a/apps/web/src/components/behavior/DiscBadge.vue b/apps/web/src/components/behavior/DiscBadge.vue new file mode 100644 index 0000000..49df1a7 --- /dev/null +++ b/apps/web/src/components/behavior/DiscBadge.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/apps/web/src/components/behavior/DiscPanel.vue b/apps/web/src/components/behavior/DiscPanel.vue new file mode 100644 index 0000000..55a55b5 --- /dev/null +++ b/apps/web/src/components/behavior/DiscPanel.vue @@ -0,0 +1,254 @@ + + +