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>
166 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|