From 7dc4344e0e19c9baec10f5c0046d14658d538eff Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 23 Sep 2026 15:49:44 +0330 Subject: [PATCH] Creating a project workflow using a voice assistant and converting it into an approval card. --- apps/api/src/ai/ai-ask.service.ts | 155 ++++++++++++ apps/api/src/ai/ai.controller.ts | 7 +- apps/api/src/ai/dto.ts | 7 + .../components/dashboard/HomeAiAssistant.vue | 230 +++++++++++++++++- packages/shared/src/index.ts | 32 +++ 5 files changed, 424 insertions(+), 7 deletions(-) diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 1bf6828..d6511d1 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -1,8 +1,12 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import type { + ActivityType, AskMentoResponse, AskMentoSourceDto, PersonAiSummaryDto, + VoiceActivityDraft, + VoiceCommandDraft, + VoicePersonDraft, } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -674,4 +678,155 @@ Rules: return { context: lines.join('\n').slice(0, 14000), catalog }; } + + async parseVoiceCommand(actor: Actor, text: string): Promise { + const now = new Date(); + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + + const raw = await this.ai.completeJson<{ + intent?: string; + summary?: string; + person?: Partial | null; + activity?: Partial | null; + }>( + `You convert one Persian voice command into a CRM action draft. +Return ONLY JSON: +{ + "intent": "create_person" | "create_activity" | "none", + "summary": string, + "person": { "firstName": string, "lastName": string, "mobile": string, "phone": string, "email": string, "website": string, "referrer": string, "position": string, "notes": string } | null, + "activity": { "title": string, "type": "call"|"meeting"|"task"|"note", "description": string, "dueAt": string|null, "personName": string } | null +} +Rules: +- create_person only when the user asks to add/create a customer or contact. +- create_activity only when the user asks to add a follow-up, call, meeting, or task. +- Otherwise intent is none. Do not invent a record. +- Do not invent phone numbers, emails, or names that were not spoken. +- Mobile/phone: digits only, Iranian mobile as 09xxxxxxxxx. +- dueAt: ISO 8601 in Asia/Tehran if a time is mentioned, else null. Today is ${today}. +- summary: one short Persian sentence describing what will be saved. +- Unused string fields are empty strings.`, + text.trim(), + ); + + const intent = + raw.intent === 'create_person' || raw.intent === 'create_activity' + ? raw.intent + : 'none'; + + if (intent === 'create_person') { + const person = cleanPerson(raw.person); + const missing: string[] = []; + if (!person.firstName) missing.push('نام'); + return { + intent, + summary: (raw.summary ?? '').trim() || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(), + missing, + canConfirm: missing.length === 0, + person, + activity: null, + }; + } + + if (intent === 'create_activity') { + const activity = cleanActivity(raw.activity); + if (activity.personName) { + activity.personId = await this.matchPersonId(actor, activity.personName); + } + const missing: string[] = []; + if (!activity.title) missing.push('عنوان فعالیت'); + return { + intent, + summary: (raw.summary ?? '').trim() || `ثبت فعالیت «${activity.title}»`, + missing, + canConfirm: missing.length === 0, + person: null, + activity, + }; + } + + return { + intent: 'none', + summary: + (raw.summary ?? '').trim() || + 'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام می‌شود.', + missing: [], + canConfirm: false, + person: null, + activity: null, + }; + } + + private async matchPersonId(actor: Actor, name: string): Promise { + const q = name.trim(); + if (q.length < 2) return null; + const rows = await this.prisma.person.findMany({ + where: { + organizationId: actor.organizationId, + ...ownerWhere(actor), + OR: [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { id: true, firstName: true, lastName: true }, + take: 5, + }); + const exact = rows.filter((row) => `${row.firstName} ${row.lastName}`.trim() === q); + if (exact.length === 1) return exact[0]!.id; + if (rows.length === 1) return rows[0]!.id; + return null; + } +} + +function cleanText(value: unknown, max = 200) { + return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max); +} + +function digitsOnly(value: unknown) { + const fa = '۰۱۲۳۴۵۶۷۸۹'; + const ar = '٠١٢٣٤٥٦٧٨٩'; + const raw = String(value ?? '') + .replace(/[۰-۹]/g, (ch) => String(fa.indexOf(ch))) + .replace(/[٠-٩]/g, (ch) => String(ar.indexOf(ch))); + let digits = raw.replace(/[^\d+]/g, ''); + if (digits.startsWith('+98')) digits = `0${digits.slice(3)}`; + else if (digits.startsWith('98') && digits.length === 12) digits = `0${digits.slice(2)}`; + return digits.slice(0, 20); +} + +function cleanPerson(input: Partial | null | undefined): VoicePersonDraft { + return { + firstName: cleanText(input?.firstName, 80), + lastName: cleanText(input?.lastName, 80), + mobile: digitsOnly(input?.mobile), + phone: digitsOnly(input?.phone), + email: cleanText(input?.email, 120), + website: cleanText(input?.website, 300), + referrer: cleanText(input?.referrer, 120), + position: cleanText(input?.position, 120), + notes: cleanText(input?.notes, 500), + }; +} + +const ACTIVITY_TYPES = new Set(['call', 'meeting', 'task', 'note']); + +function cleanActivity(input: Partial | null | undefined): VoiceActivityDraft { + const type = ACTIVITY_TYPES.has(input?.type as ActivityType) + ? (input?.type as ActivityType) + : 'task'; + const due = cleanText(input?.dueAt, 40); + return { + title: cleanText(input?.title, 160), + type, + description: cleanText(input?.description, 500), + dueAt: due && !Number.isNaN(Date.parse(due)) ? new Date(due).toISOString() : null, + personName: cleanText(input?.personName, 120), + personId: null, + }; } diff --git a/apps/api/src/ai/ai.controller.ts b/apps/api/src/ai/ai.controller.ts index 5f2b81b..1234719 100644 --- a/apps/api/src/ai/ai.controller.ts +++ b/apps/api/src/ai/ai.controller.ts @@ -3,7 +3,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { AiAskService } from './ai-ask.service'; -import { AskMentoDto } from './dto'; +import { AskMentoDto, VoiceCommandDto } from './dto'; import { AiService } from './ai.service'; @Controller('ai') @@ -19,6 +19,11 @@ export class AiController { return { enabled: this.ai.isEnabled() }; } + @Post('voice-command') + voiceCommand(@CurrentUser() user: Actor, @Body() dto: VoiceCommandDto) { + return this.ask.parseVoiceCommand(user, dto.text); + } + @Post('ask') askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) { return this.ask.ask(user, dto.question, dto.personId); diff --git a/apps/api/src/ai/dto.ts b/apps/api/src/ai/dto.ts index 1d1bd53..765dd2d 100644 --- a/apps/api/src/ai/dto.ts +++ b/apps/api/src/ai/dto.ts @@ -1,5 +1,12 @@ import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +export class VoiceCommandDto { + @IsString() + @MinLength(2) + @MaxLength(800) + text!: string; +} + export class AskMentoDto { @IsString() @MinLength(2) diff --git a/apps/web/src/components/dashboard/HomeAiAssistant.vue b/apps/web/src/components/dashboard/HomeAiAssistant.vue index fd47eb4..41789af 100644 --- a/apps/web/src/components/dashboard/HomeAiAssistant.vue +++ b/apps/web/src/components/dashboard/HomeAiAssistant.vue @@ -1,9 +1,13 @@