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;
}