Add Smart Notes AI analysis and Mento Memory with user approval.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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> {
|
||||
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 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 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: userParts.join('\n') },
|
||||
],
|
||||
}),
|
||||
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 this.normalizeExtract(JSON.parse(raw) as Partial<NoteAiExtract>);
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException || err instanceof ServiceUnavailableException) {
|
||||
throw err;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user