Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date. Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
7.1 KiB
TypeScript
207 lines
7.1 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|