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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user