Files
CRM_Montzar/apps/api/src/ai/ai.service.ts
T
MahdiandCursor cf326d1ed9 fix: keep birthdays and show Shamsi dates
Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:04:53 +03:30

207 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { rewriteGregorianDates } from '../common/jalali';
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.
- If summary mentions a date, write it Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.
- 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 summarizeCallTranscript(input: {
transcript: string;
personName?: string | null;
direction?: string | null;
}): Promise<{ summary: string; nextAction: string | null }> {
const system = `You summarize phone call transcripts for a Persian CRM.
Return ONLY valid JSON:
{
"summary": string,
"nextAction": string | null
}
Rules:
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
- nextAction: one short Persian follow-up if needed; otherwise null.
- Do not invent facts not in the transcript.
- Keep names, numbers, and amounts accurate.
- Dates in summary and nextAction must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.`;
const userParts = [
input.personName ? `Customer: ${input.personName}` : null,
input.direction ? `Direction: ${input.direction}` : null,
`Transcript:\n${input.transcript.trim().slice(0, 12000)}`,
].filter(Boolean);
const raw = await this.completeJson<{
summary?: string;
nextAction?: string | null;
}>(system, userParts.join('\n'));
const summary = rewriteGregorianDates(String(raw.summary ?? '').trim()).slice(0, 4000);
if (!summary) {
throw new BadRequestException('خلاصه تماس خالی بود');
}
const nextAction = raw.nextAction
? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null
: null;
return { summary, nextAction };
}
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: rewriteGregorianDates(String(raw.nextAction.title ?? '').trim()).slice(0, 200),
description: raw.nextAction.description
? rewriteGregorianDates(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: rewriteGregorianDates(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 ? rewriteGregorianDates(String(raw.summary).trim()).slice(0, 500) : undefined,
};
}
}