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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(/\/$/, '');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user