using System.Text.Json; using GB5Shared.FileUpload; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.GenerateAutoNumber; using GB5Shared.RecordingIntelligence; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using ECPBLL.Meeting.Interfaces; using ECPDAL.Meeting.DTOs; using ECPDAL.Meeting.Interfaces; using Microsoft.Extensions.Logging; namespace ECPBLL.Meeting.Implementations; public class RecordingTranscriptBLL( IRecordingTranscriptDAL _dal, IAICapabilityService _aiCapabilityService, IFileUploadBLL _fileUploadBLL, AutoNumber _autoNumber, KeyInvalidate _keyInvalidate, EventLogPublish _eventLog, ILogger _logger ) : IRecordingTranscriptBLL { // PropertyNamingPolicy left at its default (preserve declared PascalCase names) -- see // Communication/CommunicationBLL/Implementations/CommentThreadBLL.cs for why this override // was removed. private static readonly JsonSerializerOptions JsonOpts = new(); // ── GET ─────────────────────────────────────────────────────────── public async Task GetRecordingTranscriptAsync(int recordingTranscriptId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-recording-transcript", new { recordingTranscriptId }); var record = await _dal.GetRecordingTranscriptAsync(recordingTranscriptId, login, ct).ConfigureAwait(false); if (record is null) throw new InvalidOperationException($"Recording transcript {recordingTranscriptId} not found."); return JsonSerializer.Serialize(record, JsonOpts); } public async Task GetRecordingTranscriptsForObjectAsync(int objectTypeId, int objectId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-recording-transcripts-for-object", new { objectTypeId, objectId }); var list = await _dal.GetRecordingTranscriptsForObjectAsync(objectTypeId, objectId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list, JsonOpts); } // ── ATTACH (bring your own recording — v1 has no capture pipeline) ── public async Task AttachRecordingAsync(AttachRecordingDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-attach-recording", new { dto.ObjectTypeId, dto.ObjectId, dto.RecordingAttachmentId }); if (dto.ObjectTypeId == 0) throw new InvalidOperationException("ObjectTypeId is required."); if (dto.ObjectId == 0) throw new InvalidOperationException("ObjectId is required."); if (dto.RecordingAttachmentId == 0) throw new InvalidOperationException("RecordingAttachmentId is required — upload the recording via the existing FileUpload/ECM pattern first."); try { var autoNumber = await _autoNumber.GetNumberAsync(1, "RECORDINGTRANSCRIPT", login).ConfigureAwait(false); var newId = autoNumber.StartNumber; var entity = new RecordingTranscriptDTO { RecordingTranscriptId = newId, ObjectTypeId = dto.ObjectTypeId, ObjectId = dto.ObjectId, RecordingAttachmentId = dto.RecordingAttachmentId, ProcessingStatus = MeetingConstants.RecordingProcessingStatus.Pending, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Version = 0, Status = MeetingConstants.RowStatus.Active, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }; GB5Trace.Step("save-recording-transcript", new { newId }); await _dal.SaveRecordingTranscriptAsync(entity, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.RECORDINGTRANSCRIPTUPLOADEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Recording Transcript Uploaded", entity, Constant.EventTypeConstant.RECORDINGTRANSCRIPTUPLOADEDEVENTTYPEID, newId, login, ct: ct) .ConfigureAwait(false); await InvalidateObjectCacheAsync(dto.ObjectTypeId, dto.ObjectId, login).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {newId}"; } catch (Exception ex) { GB5Trace.MarkFailed("attach-recording-failed", ex); _logger.LogError(ex, "AttachRecording failed for ObjectType {ObjectTypeId} Object {ObjectId}", dto.ObjectTypeId, dto.ObjectId); throw; } } // ── TRANSCRIBE + SUMMARIZE ────────────────────────────────────────── public async Task TranscribeAndSummarizeAsync(int recordingTranscriptId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-transcribe-recording", new { recordingTranscriptId }); var record = await _dal.GetRecordingTranscriptAsync(recordingTranscriptId, login, ct).ConfigureAwait(false); if (record is null) throw new InvalidOperationException($"Recording transcript {recordingTranscriptId} not found."); try { var attachment = await _fileUploadBLL.DownloadAttachmentAsBase64(record.RecordingAttachmentId, login) .ConfigureAwait(false); var audioBytes = Convert.FromBase64String(attachment.Base64Content); GB5Trace.Step("transcribe-recording", new { recordingTranscriptId }); var transcript = await _aiCapabilityService .TranscribeAsync(audioBytes, attachment.MimeType, ct) .ConfigureAwait(false); await _dal.MarkTranscribedAsync(recordingTranscriptId, transcript.TranscriptText, login.UserId, login, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.RECORDINGTRANSCRIPTTRANSCRIBEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Recording Transcript Transcribed", transcript, Constant.EventTypeConstant.RECORDINGTRANSCRIPTTRANSCRIBEDEVENTTYPEID, recordingTranscriptId, login, ct: ct) .ConfigureAwait(false); GB5Trace.Step("summarize-recording", new { recordingTranscriptId }); var summary = await _aiCapabilityService .SummarizeAsync(transcript.TranscriptText, ct) .ConfigureAwait(false); await _dal.MarkSummarizedAsync(recordingTranscriptId, summary.SummaryText, login.UserId, login, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.RECORDINGTRANSCRIPTSUMMARIZEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Recording Transcript Summarized", summary, Constant.EventTypeConstant.RECORDINGTRANSCRIPTSUMMARIZEDEVENTTYPEID, recordingTranscriptId, login, ct: ct) .ConfigureAwait(false); await InvalidateObjectCacheAsync(record.ObjectTypeId, record.ObjectId, login).ConfigureAwait(false); await InvalidateDetailCacheAsync(recordingTranscriptId, login).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { // IAICapabilityService is explicitly unconfigured in this phase — the default // NotConfiguredAICapabilityService always throws InvalidOperationException. Per the // plan, that (and any other processing failure, e.g. attachment download) must be // handled gracefully here: mark PROCESSINGSTATUS=4/failed with a logged, persisted // reason and return a normal result, never let it bubble as an unhandled 500. GB5Trace.MarkFailed("recording-transcript-processing-failed", ex); _logger.LogError(ex, "TranscribeAndSummarize failed for RecordingTranscriptId {RecordingTranscriptId}", recordingTranscriptId); await _dal.MarkFailedAsync(recordingTranscriptId, ex.Message, login.UserId, login, ct).ConfigureAwait(false); try { await _eventLog.PublishEventLogAsync( "Recording Transcript Failed", new { recordingTranscriptId, Reason = ex.Message }, Constant.EventTypeConstant.RECORDINGTRANSCRIPTFAILEDEVENTTYPEID, recordingTranscriptId, login, ct: ct) .ConfigureAwait(false); } catch (Exception publishEx) { // Never let audit-event publishing failure mask the original processing failure. GB5Trace.MarkFailed("recording-transcript-failed-event-publish-failed", publishEx); _logger.LogWarning(publishEx, "Failed to publish RecordingTranscript.Failed event for RecordingTranscriptId {RecordingTranscriptId}", recordingTranscriptId); } await InvalidateObjectCacheAsync(record.ObjectTypeId, record.ObjectId, login).ConfigureAwait(false); await InvalidateDetailCacheAsync(recordingTranscriptId, login).ConfigureAwait(false); return $"Recording transcript processing failed: {ex.Message}"; } } public async Task DeleteRecordingTranscriptAsync(int recordingTranscriptId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("delete-recording-transcript", new { recordingTranscriptId }); try { var record = await _dal.GetRecordingTranscriptAsync(recordingTranscriptId, login, ct).ConfigureAwait(false); if (record is null) throw new InvalidOperationException($"Recording transcript {recordingTranscriptId} not found."); await _dal.DeleteRecordingTranscriptAsync(recordingTranscriptId, login.UserId, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.RECORDINGTRANSCRIPTDELETEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Recording Transcript Deleted", new { recordingTranscriptId }, Constant.EventTypeConstant.RECORDINGTRANSCRIPTDELETEDEVENTTYPEID, recordingTranscriptId, login, ct: ct) .ConfigureAwait(false); await InvalidateObjectCacheAsync(record.ObjectTypeId, record.ObjectId, login).ConfigureAwait(false); await InvalidateDetailCacheAsync(recordingTranscriptId, login).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("delete-recording-transcript-failed", ex); _logger.LogError(ex, "DeleteRecordingTranscript failed for RecordingTranscriptId {RecordingTranscriptId}", recordingTranscriptId); throw; } } // Clears GetRecordingTranscriptsForObject's cache (keyed "{objectTypeId}:{objectId}"). private async Task InvalidateObjectCacheAsync(int objectTypeId, int objectId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( $"{objectTypeId}:{objectId}", Constant.EntityConstant.OBJECTRECORDINGTRANSCRIPT, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } // Clears GetRecordingTranscript's own per-id cache (keyed by RecordingTranscriptId — same // OBJECTRECORDINGTRANSCRIPT entity as the object-list key above but a different key string). // Needed by TranscribeAndSummarize (both the success and failure paths — status/text change // either way) and DeleteRecordingTranscript. private async Task InvalidateDetailCacheAsync(int recordingTranscriptId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( recordingTranscriptId, Constant.EntityConstant.OBJECTRECORDINGTRANSCRIPT, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } }