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('AI_ENABLED') === 'true' && Boolean(this.config.get('AI_API_KEY')?.trim()) ); } async extractFromNote(input: { body: string; title?: string | null; noteType?: string; personName?: string | null; }): Promise { 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>( system, userParts.join('\n'), ); return this.normalizeExtract(raw); } async completeJson(system: string, user: string): Promise { if (!this.isEnabled()) { throw new ServiceUnavailableException('هوش مصنوعی فعال نیست'); } const model = this.config.get('AI_MODEL')?.trim() || 'gpt-4o-mini'; const baseUrl = this.config.get('AI_BASE_URL')?.trim() || 'https://api.openai.com/v1'; const apiKey = this.config.get('AI_API_KEY')!.trim(); const timeoutMs = Number(this.config.get('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 { 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, }; } }