import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { AiService } from '../../ai/ai.service'; import { NotesService } from '../../notes/notes.service'; import { PrismaService } from '../../prisma/prisma.service'; import { VoipConnectionsService } from './connections.service'; import { DaftaresomaClient } from './daftareshoma-client'; import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook'; import { SttClientService } from './stt-client.service'; const MAX_ATTEMPTS = 3; @Injectable() export class CallSummaryService { private readonly logger = new Logger(CallSummaryService.name); private running = false; constructor( private readonly prisma: PrismaService, private readonly stt: SttClientService, private readonly ai: AiService, private readonly notes: NotesService, private readonly connections: VoipConnectionsService, private readonly daftareshoma: DaftaresomaClient, ) {} @Cron('*/1 * * * *') async processDue(): Promise { if (this.running) return 0; this.running = true; try { const now = new Date(); const rows = await this.prisma.voipCallLog.findMany({ where: { summaryStatus: 'pending', OR: [ { summaryNextAttemptAt: null }, { summaryNextAttemptAt: { lte: now } }, ], }, orderBy: { syncedAt: 'asc' }, take: 5, }); let processed = 0; for (const row of rows) { await this.processOne(row.id); processed += 1; } return processed; } finally { this.running = false; } } private async processOne(id: string): Promise { const claimed = await this.prisma.voipCallLog.updateMany({ where: { id, summaryStatus: 'pending' }, data: { summaryStatus: 'processing', summaryAttempts: { increment: 1 }, summaryError: null, }, }); if (!claimed.count) return; const row = await this.prisma.voipCallLog.findUnique({ where: { id } }); if (!row) return; try { let transcript = (row.transcript ?? '').trim(); if (!transcript && row.recordingUrl) { if (!this.stt.isEnabled()) { await this.markFailed( id, row.summaryAttempts, 'سرویس تبدیل گفتار فعال نیست', true, ); return; } const daftId = parseDaftaresomaRecordingRef(row.recordingUrl); if (daftId) { const conn = await this.prisma.voipConnection.findFirst({ where: { id: row.connectionId }, }); const token = conn ? await this.connections.decryptApiToken(conn) : null; if (!token) { await this.markFailed( id, row.summaryAttempts, 'توکن API دفتر شما برای دانلود ضبط موجود نیست', true, ); return; } const audio = await this.daftareshoma.downloadRecording( token, daftId, ); transcript = await this.stt.transcribeAudio(audio); } else { transcript = await this.stt.downloadAndTranscribe(row.recordingUrl); } await this.prisma.voipCallLog.update({ where: { id }, data: { transcript }, }); } if (!transcript) { await this.prisma.voipCallLog.update({ where: { id }, data: { summaryStatus: 'skipped', summaryError: 'ضبط یا متن تماس ارسال نشده', summaryNextAttemptAt: null, }, }); return; } if (!this.ai.isEnabled()) { await this.markFailed( id, row.summaryAttempts, 'هوش مصنوعی فعال نیست', true, ); return; } let personName: string | null = null; if (row.personId) { const person = await this.prisma.person.findFirst({ where: { id: row.personId }, select: { firstName: true, lastName: true }, }); if (person) { personName = `${person.firstName} ${person.lastName}`.trim(); } } const { summary, nextAction } = await this.ai.summarizeCallTranscript({ transcript, personName, direction: row.direction, }); const authorId = await this.resolveAuthorId(row.organizationId, row.personId); if (!authorId) { await this.markFailed( id, row.summaryAttempts, 'کاربر مالک/مدیر برای ثبت یادداشت پیدا نشد', true, ); return; } const bodyParts = [ summary, nextAction ? `\nاقدام بعدی پیشنهادی: ${nextAction}` : null, '\n---\nمتن تماس:\n', transcript.slice(0, 8000), ].filter(Boolean); const note = await this.notes.create(row.organizationId, authorId, { title: personName ? `خلاصه تماس — ${personName}` : 'خلاصه تماس تلفنی', body: bodyParts.join(''), noteType: 'call_result', tags: ['voip', 'auto-summary'], personId: row.personId, activityId: row.activityId, analyzeEnabled: true, visibility: 'team', }); if (row.activityId) { const activity = await this.prisma.activity.findFirst({ where: { id: row.activityId }, select: { description: true }, }); if (activity) { const base = (activity.description ?? '').trim(); const block = `خلاصه خودکار:\n${summary}`; const nextDesc = base.includes('خلاصه خودکار:') ? base.replace(/خلاصه خودکار:[\s\S]*$/m, block) : [base, block].filter(Boolean).join('\n\n'); await this.prisma.activity.update({ where: { id: row.activityId }, data: { description: nextDesc.slice(0, 8000) }, }); } } await this.prisma.voipCallLog.update({ where: { id }, data: { summary, summaryStatus: 'done', summaryNoteId: note.id, summaryError: null, summaryNextAttemptAt: null, }, }); } catch (err) { const message = err instanceof Error ? err.message.slice(0, 400) : 'خطای ناشناخته'; this.logger.warn(`call summary ${id}: ${message}`); await this.markFailed(id, row.summaryAttempts, message, false); } } private async markFailed( id: string, attemptsSoFar: number, error: string, permanent: boolean, ): Promise { const attempts = attemptsSoFar; if (permanent || attempts >= MAX_ATTEMPTS) { await this.prisma.voipCallLog.update({ where: { id }, data: { summaryStatus: 'failed', summaryError: error, summaryNextAttemptAt: null, }, }); return; } const delayMin = Math.min(15, 2 ** Math.max(0, attempts - 1)); await this.prisma.voipCallLog.update({ where: { id }, data: { summaryStatus: 'pending', summaryError: error, summaryNextAttemptAt: new Date(Date.now() + delayMin * 60_000), }, }); } private async resolveAuthorId( organizationId: string, personId: string | null, ): Promise { if (personId) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, select: { ownerId: true }, }); if (person?.ownerId) return person.ownerId; } const actor = await this.prisma.membership.findFirst({ where: { organizationId, isActive: true, role: { in: ['owner', 'admin'] }, }, select: { userId: true }, orderBy: { createdAt: 'asc' }, }); return actor?.userId ?? null; } }