Files
CRM_Montzar/apps/api/src/ai/ai.service.ts
T
MahdiandCursor 7757c0837d 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>
2026-09-03 14:20:55 +03:30

166 lines
5.4 KiB
TypeScript

import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type NoteAiExtract = {
nextAction: {
title: string;
description?: string;
dueAt?: string | null;
} | null;
memoryItems: Array<{ content: string; category?: string }>;
summary?: string;
};
@Injectable()
export class AiService {
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
return (
this.config.get<string>('AI_ENABLED') === 'true' &&
Boolean(this.config.get<string>('AI_API_KEY')?.trim())
);
}
async extractFromNote(input: {
body: string;
title?: string | null;
noteType?: string;
personName?: string | null;
}): Promise<NoteAiExtract> {
const system = `You analyze CRM notes in Persian/Farsi and extract structured data.
Return ONLY valid JSON with this exact shape:
{
"nextAction": { "title": string, "description"?: string, "dueAt"?: string|null } | null,
"memoryItems": [{ "content": string, "category"?: string }],
"summary"?: string
}
Rules:
- nextAction: one concrete follow-up task if the note implies action; otherwise null.
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null.
- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
- Keep text in Persian when the note is Persian.
- Do not invent facts not supported by the note.
- Maximum 5 memory items.`;
const userParts = [
input.title ? `Title: ${input.title}` : null,
input.noteType ? `Type: ${input.noteType}` : null,
input.personName ? `Customer: ${input.personName}` : null,
`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);
try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
temperature: 0.2,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
}),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new ServiceUnavailableException(
`خطا در ارتباط با مدل هوش مصنوعی (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`,
);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const raw = data.choices?.[0]?.message?.content?.trim();
if (!raw) {
throw new BadRequestException('پاسخ مدل خالی بود');
}
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('ارتباط با مدل هوش مصنوعی ناموفق بود');
} finally {
clearTimeout(timer);
}
}
private normalizeExtract(raw: Partial<NoteAiExtract>): NoteAiExtract {
const nextAction =
raw.nextAction && typeof raw.nextAction === 'object'
? {
title: String(raw.nextAction.title ?? '').trim().slice(0, 200),
description: raw.nextAction.description
? String(raw.nextAction.description).trim().slice(0, 2000)
: undefined,
dueAt: raw.nextAction.dueAt
? String(raw.nextAction.dueAt).trim()
: null,
}
: null;
const memoryItems = Array.isArray(raw.memoryItems)
? raw.memoryItems
.map((item) => ({
content: String(item?.content ?? '').trim().slice(0, 500),
category: item?.category
? String(item.category).trim().slice(0, 40)
: undefined,
}))
.filter((item) => item.content.length > 0)
.slice(0, 5)
: [];
const cleanedNext =
nextAction && nextAction.title.length > 0 ? nextAction : null;
return {
nextAction: cleanedNext,
memoryItems,
summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined,
};
}
}