Creating a project workflow using a voice assistant and converting it into an approval card.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user