Add Ask Mento, customer mood, and Pipecat speech-to-text.

Ship assistant answers, contact moods, voice dictation, and the related CRM UI so these can be deployed to production.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 14:20:55 +03:30
co-authored by Cursor
parent 0dd2966f3c
commit 7757c0837d
43 changed files with 5156 additions and 1741 deletions
+236
View File
@@ -0,0 +1,236 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
AskMentoResponse,
AskMentoSourceDto,
PersonAiSummaryDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
assigneeWhere,
noteAccessWhere,
ownerWhere,
} from '../common/record-scope';
import { AiService } from './ai.service';
type CatalogItem = AskMentoSourceDto;
function clip(value: string | null | undefined, max = 280) {
const text = (value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
return text.length > max ? `${text.slice(0, max)}…` : text;
}
@Injectable()
export class AiAskService {
constructor(
private readonly prisma: PrismaService,
private readonly ai: AiService,
) {}
async ask(
actor: Actor,
question: string,
personId?: string,
): Promise<AskMentoResponse> {
const { context, catalog } = await this.buildContext(actor, personId);
const raw = await this.ai.completeJson<{
answer?: string;
sources?: Array<{ type?: string; id?: string; label?: string }>;
}>(
`You are Mento, a Persian CRM sales assistant.
Answer ONLY from the CRM context. If the context is insufficient, say so in Persian.
Do not invent customers, amounts, dates, or promises.
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
Use at most 4 sources, and only ids that appear in the context.
Write the answer in Persian, concise, with dates when present.`,
`Question:\n${question.trim()}\n\nCRM context:\n${context}`,
);
const allowed = new Map(catalog.map((item) => [item.id, item]));
const sources = (Array.isArray(raw.sources) ? raw.sources : [])
.map((item) => allowed.get(String(item?.id ?? '')))
.filter((item): item is CatalogItem => Boolean(item))
.slice(0, 4);
const answer = String(raw.answer ?? '').trim().slice(0, 2000);
return {
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
sources,
};
}
async summarizePerson(actor: Actor, personId: string): Promise<PersonAiSummaryDto> {
const { context } = await this.buildContext(actor, personId);
const raw = await this.ai.completeJson<{
text?: string;
cta?: string;
ctaKind?: string;
}>(
`You write a short live briefing for a CRM customer card in Persian.
Return JSON: { "text": string, "cta": string, "ctaKind": "call"|"activity" }
Rules:
- 2 to 4 short sentences from the context only.
- Mention durable memory, last contact, open deals, or next follow-up when present.
- cta is one concrete next step, starting with «پیشنهاد منتو:».
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
- Do not invent facts.`,
`Customer briefing context:\n${context}`,
);
const text = String(raw.text ?? '').trim().slice(0, 700);
const cta = String(raw.cta ?? '').trim().slice(0, 120);
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
return {
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
cta: cta || 'پیشنهاد منتو: امروز با مشتری تماس بگیر',
ctaKind,
};
}
private async buildContext(actor: Actor, personId?: string) {
if (personId) {
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
include: { company: { select: { name: true } } },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
}
const [notes, activities, deals, memories] = await Promise.all([
this.prisma.note.findMany({
where: {
organizationId: actor.organizationId,
...noteAccessWhere(actor),
...(personId ? { personId } : {}),
},
select: {
id: true,
title: true,
body: true,
createdAt: true,
person: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: personId ? 15 : 8,
}),
this.prisma.activity.findMany({
where: {
organizationId: actor.organizationId,
...assigneeWhere(actor),
...(personId ? { personId } : {}),
},
select: {
id: true,
type: true,
status: true,
title: true,
description: true,
dueAt: true,
person: { select: { firstName: true, lastName: true } },
},
orderBy: { updatedAt: 'desc' },
take: personId ? 15 : 10,
}),
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(personId ? { personId } : { status: 'open' }),
},
select: {
id: true,
title: true,
amount: true,
currency: true,
status: true,
isStale: true,
isRotten: true,
person: { select: { firstName: true, lastName: true } },
stage: { select: { name: true } },
},
orderBy: { updatedAt: 'desc' },
take: 10,
}),
personId
? this.prisma.memoryItem.findMany({
where: { organizationId: actor.organizationId, personId },
select: { id: true, content: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 8,
})
: Promise.resolve([]),
]);
const catalog: CatalogItem[] = [];
const lines: string[] = [
`Today: ${new Date().toISOString()}`,
personId ? `Focus personId: ${personId}` : 'Scope: current user records',
];
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId },
include: { company: { select: { name: true } } },
});
if (person) {
catalog.push({
type: 'person',
id: person.id,
label: `${person.firstName} ${person.lastName}`.trim(),
});
lines.push(
`Person: ${person.firstName} ${person.lastName} | position: ${person.position ?? '—'} | company: ${person.company?.name ?? '—'} | mood: ${person.mood} | phone: ${person.mobile || person.phone || '—'} | email: ${person.email ?? '—'}`,
);
}
}
for (const item of memories) {
catalog.push({ type: 'memory', id: item.id, label: clip(item.content, 60) });
lines.push(`Memory ${item.id}: ${clip(item.content, 220)}`);
}
for (const note of notes) {
const who = note.person
? `${note.person.firstName} ${note.person.lastName}`
: '';
catalog.push({
type: 'note',
id: note.id,
label: clip(note.title || note.body, 60),
});
lines.push(
`Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
);
}
for (const activity of activities) {
const who = activity.person
? `${activity.person.firstName} ${activity.person.lastName}`
: '';
catalog.push({ type: 'activity', id: activity.id, label: activity.title });
lines.push(
`Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
);
}
for (const deal of deals) {
const who = deal.person
? `${deal.person.firstName} ${deal.person.lastName}`
: '';
catalog.push({ type: 'deal', id: deal.id, label: deal.title });
const flags = [
deal.isStale ? 'stale' : null,
deal.isRotten ? 'rotten' : null,
]
.filter(Boolean)
.join(',');
lines.push(
`Deal ${deal.id} ${deal.title} status=${deal.status} stage=${deal.stage.name} amount=${deal.amount != null ? String(deal.amount) : '—'} ${deal.currency}${flags ? ` flags=${flags}` : ''}${who ? ` person=${who}` : ''}`,
);
}
return { context: lines.join('\n').slice(0, 12000), catalog };
}
}
+80
View File
@@ -0,0 +1,80 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
@Injectable()
export class AiSttService {
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
return (
this.config.get<string>('STT_ENABLED') === 'true' &&
Boolean(this.sttUrl())
);
}
async transcribe(file: Express.Multer.File | undefined) {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('تبدیل صوت به متن فعال نیست');
}
if (!file?.buffer?.length) {
throw new BadRequestException('فایل صوتی ارسال نشده است');
}
if (file.size > MAX_AUDIO_BYTES) {
throw new BadRequestException('حجم فایل صوتی بیش از حد مجاز است');
}
const form = new FormData();
form.append(
'audio',
new Blob([new Uint8Array(file.buffer)], {
type: file.mimetype || 'audio/wav',
}),
file.originalname || 'audio.wav',
);
const timeoutMs = Number(this.config.get<string>('STT_TIMEOUT_MS') || 60000);
const headers: Record<string, string> = {};
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim();
if (token) headers['X-STT-Token'] = token;
let res: Response;
try {
res = await fetch(`${this.sttUrl()}/transcribe`, {
method: 'POST',
headers,
body: form,
signal: AbortSignal.timeout(timeoutMs),
});
} catch {
throw new ServiceUnavailableException(
'سرویس تبدیل صوت در دسترس نیست. Pipecat STT را اجرا کنید.',
);
}
const payload = (await res.json().catch(() => ({}))) as {
text?: string;
detail?: string | Array<{ msg?: string }>;
};
if (!res.ok) {
const detail = Array.isArray(payload.detail)
? payload.detail.map((item) => item.msg).filter(Boolean).join('، ')
: payload.detail;
throw new ServiceUnavailableException(
detail || 'تبدیل صوت به متن ناموفق بود',
);
}
return { text: (payload.text ?? '').trim() };
}
private sttUrl(): string {
return (this.config.get<string>('STT_URL') ?? '').trim().replace(/\/$/, '');
}
}
+58
View File
@@ -0,0 +1,58 @@
import {
Body,
Controller,
Get,
Param,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
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 { AiService } from './ai.service';
import { AiSttService } from './ai-stt.service';
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiController {
constructor(
private readonly ai: AiService,
private readonly ask: AiAskService,
private readonly stt: AiSttService,
) {}
@Get('status')
status() {
return { enabled: this.ai.isEnabled(), stt: this.stt.isEnabled() };
}
@Post('transcribe')
@UseInterceptors(
FileInterceptor('audio', {
storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 },
}),
)
transcribe(@UploadedFile() file: Express.Multer.File) {
return this.stt.transcribe(file);
}
@Post('ask')
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
return this.ask.ask(user, dto.question, dto.personId);
}
@Get('people/:personId/summary')
personSummary(
@CurrentUser() user: Actor,
@Param('personId') personId: string,
) {
return this.ask.summarizePerson(user, personId);
}
}
+6 -2
View File
@@ -1,9 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { AiAskService } from './ai-ask.service';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiSttService } from './ai-stt.service';
@Global()
@Module({
providers: [AiService],
exports: [AiService],
controllers: [AiController],
providers: [AiService, AiAskService, AiSttService],
exports: [AiService, AiSttService],
})
export class AiModule {}
+25 -14
View File
@@ -32,17 +32,6 @@ export class AiService {
noteType?: string;
personName?: string | null;
}): Promise<NoteAiExtract> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
}
const model = this.config.get<string>('AI_MODEL')?.trim() || 'gpt-4o-mini';
const baseUrl =
this.config.get<string>('AI_BASE_URL')?.trim() ||
'https://api.openai.com/v1';
const apiKey = this.config.get<string>('AI_API_KEY')!.trim();
const timeoutMs = Number(this.config.get<string>('AI_TIMEOUT_MS') || 45000);
const system = `You analyze CRM notes in Persian/Farsi and extract structured data.
Return ONLY valid JSON with this exact shape:
{
@@ -65,6 +54,25 @@ Rules:
`Note:\n${input.body.trim()}`,
].filter(Boolean);
const raw = await this.completeJson<Partial<NoteAiExtract>>(
system,
userParts.join('\n'),
);
return this.normalizeExtract(raw);
}
async completeJson<T>(system: string, user: string): Promise<T> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
}
const model = this.config.get<string>('AI_MODEL')?.trim() || 'gpt-4o-mini';
const baseUrl =
this.config.get<string>('AI_BASE_URL')?.trim() ||
'https://api.openai.com/v1';
const apiKey = this.config.get<string>('AI_API_KEY')!.trim();
const timeoutMs = Number(this.config.get<string>('AI_TIMEOUT_MS') || 45000);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -81,7 +89,7 @@ Rules:
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userParts.join('\n') },
{ role: 'user', content: user },
],
}),
signal: controller.signal,
@@ -102,15 +110,18 @@ Rules:
throw new BadRequestException('پاسخ مدل خالی بود');
}
return this.normalizeExtract(JSON.parse(raw) as Partial<NoteAiExtract>);
return JSON.parse(raw) as T;
} catch (err) {
if (err instanceof BadRequestException || err instanceof ServiceUnavailableException) {
throw err;
}
if (err instanceof SyntaxError) {
throw new BadRequestException('پاسخ مدل قابل خواندن نبود');
}
if (err instanceof Error && err.name === 'AbortError') {
throw new ServiceUnavailableException('زمان پاسخ مدل هوش مصنوعی تمام شد');
}
throw new ServiceUnavailableException('تحلیل یادداشت با خطا مواجه شد');
throw new ServiceUnavailableException('ارتباط با مدل هوش مصنوعی ناموفق بود');
} finally {
clearTimeout(timer);
}
+12
View File
@@ -0,0 +1,12 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class AskMentoDto {
@IsString()
@MinLength(2)
@MaxLength(500)
question!: string;
@IsOptional()
@IsString()
personId?: string;
}
@@ -1,6 +1,7 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
import type { ImportContactsResult } from '@mento/shared';
import type { ImportContactsResult, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
@@ -21,6 +22,13 @@ function cellStr(value: ExcelJS.CellValue | undefined): string {
return String(value).trim();
}
function parseMood(raw?: string): PersonMood | undefined {
const value = raw?.trim();
if (!value) return undefined;
if (PERSON_MOODS.includes(value as PersonMood)) return value as PersonMood;
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
}
function normalizeHeader(raw: string) {
return raw.trim().toLowerCase().replace(/\s+/g, '');
}
@@ -44,6 +52,10 @@ const PERSON_ALIASES: Record<string, string> = {
شرکت: 'companyName',
notes: 'notes',
یادداشت: 'notes',
mood: 'mood',
وضعیت: 'mood',
وضعیتمشتری: 'mood',
'وضعیت مشتری': 'mood',
};
const COMPANY_ALIASES: Record<string, string> = {
@@ -86,6 +98,7 @@ export class ContactsExcelService {
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'سمت', key: 'position', width: 18 },
{ header: 'شرکت', key: 'companyName', width: 24 },
{ header: 'وضعیت مشتری', key: 'mood', width: 18 },
{ header: 'یادداشت', key: 'notes', width: 32 },
];
for (const p of items) {
@@ -97,6 +110,7 @@ export class ContactsExcelService {
email: p.email ?? '',
position: p.position ?? '',
companyName: p.company?.name ?? '',
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
notes: p.notes ?? '',
});
}
@@ -174,6 +188,7 @@ export class ContactsExcelService {
mobile: row.mobile || undefined,
position: row.position || undefined,
notes: row.notes || undefined,
mood: parseMood(row.mood),
companyId,
});
result.created += 1;
+18
View File
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsIn,
IsInt,
IsObject,
IsOptional,
@@ -11,6 +12,7 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import { PERSON_MOODS } from '@mento/shared';
export class ListQueryDto {
@IsOptional()
@@ -29,6 +31,14 @@ export class ListQueryDto {
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsOptional()
@IsIn(['name', 'newest'])
sort?: 'name' | 'newest';
}
export class CreateCompanyDto {
@@ -160,6 +170,10 @@ export class CreatePersonDto {
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -213,6 +227,10 @@ export class UpdatePersonDto {
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -37,6 +37,7 @@ export class PeopleController {
query.q,
query.page ?? 1,
query.limit ?? 20,
{ mood: query.mood, sort: query.sort },
);
}
+11 -1
View File
@@ -7,6 +7,7 @@ import type {
CreatePersonRequest,
PaginatedResponse,
PersonDto,
PersonMood,
UpdatePersonRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -41,6 +42,7 @@ type PersonRow = {
mobile: string | null;
position: string | null;
notes: string | null;
mood: PersonMood;
customFields: unknown;
loyaltyPoints: number;
createdAt: Date;
@@ -67,10 +69,12 @@ export class PeopleService {
q?: string,
page = 1,
limit = 20,
opts?: { mood?: PersonMood; sort?: 'name' | 'newest' },
): Promise<PaginatedResponse<PersonDto>> {
const where: Prisma.PersonWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
...(q?.trim()
? {
OR: [
@@ -89,7 +93,10 @@ export class PeopleService {
this.prisma.person.findMany({
where,
include: personInclude,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
orderBy:
opts?.sort === 'newest'
? [{ createdAt: 'desc' }]
: [{ lastName: 'asc' }, { firstName: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
@@ -128,6 +135,7 @@ export class PeopleService {
mobile: normalizePhone(dto.mobile),
position: emptyToNull(dto.position),
notes: emptyToNull(dto.notes),
mood: dto.mood ?? 'newcomer',
companyId,
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
@@ -156,6 +164,7 @@ export class PeopleService {
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if (dto.mood !== undefined) data.mood = dto.mood;
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
@@ -238,6 +247,7 @@ export class PeopleService {
mobile: row.mobile,
position: row.position,
notes: row.notes,
mood: row.mood,
customFields: asCustomFields(row.customFields),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,