2026-09-03 14:20:55 +03:30
|
|
|
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';
|
2026-09-03 16:45:59 +03:30
|
|
|
import { BUSINESS_FIELD_LABELS, isBusinessField } from '@mento/shared';
|
2026-09-03 14:20:55 +03:30
|
|
|
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('شخص پیدا نشد');
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 16:45:59 +03:30
|
|
|
const [org, notes, activities, deals, memories] = await Promise.all([
|
|
|
|
|
this.prisma.organization.findUnique({
|
|
|
|
|
where: { id: actor.organizationId },
|
|
|
|
|
select: { name: true, businessField: true },
|
|
|
|
|
}),
|
2026-09-03 14:20:55 +03:30
|
|
|
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[] = [];
|
2026-09-03 16:45:59 +03:30
|
|
|
const field =
|
|
|
|
|
org?.businessField && isBusinessField(org.businessField)
|
|
|
|
|
? BUSINESS_FIELD_LABELS[org.businessField]
|
|
|
|
|
: 'نامشخص';
|
2026-09-03 14:20:55 +03:30
|
|
|
const lines: string[] = [
|
|
|
|
|
`Today: ${new Date().toISOString()}`,
|
2026-09-03 16:45:59 +03:30
|
|
|
`Organization: ${org?.name ?? '—'} | business field: ${field}`,
|
2026-09-03 14:20:55 +03:30
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
}
|