Creating a project workflow using a voice assistant and converting it into an approval card.

This commit is contained in:
2026-09-23 15:49:44 +03:30
parent 99df327ded
commit 7dc4344e0e
5 changed files with 424 additions and 7 deletions
+155
View File
@@ -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<VoiceCommandDraft> {
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<VoicePersonDraft> | null;
activity?: Partial<VoiceActivityDraft> | 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<string | null> {
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<VoicePersonDraft> | 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<ActivityType>(['call', 'meeting', 'task', 'note']);
function cleanActivity(input: Partial<VoiceActivityDraft> | 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,
};
}
+6 -1
View File
@@ -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);
+7
View File
@@ -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)