diff --git a/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs b/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs index fd73f18..4b6a777 100644 --- a/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs +++ b/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs @@ -26,6 +26,9 @@ public class ChatRequest [JsonPropertyName("instruction")] public string? Instruction { get; set; } + [JsonPropertyName("use_tts")] + public bool UseTTS { get; set; } = true; + public ProcessChatCommand ToProcessChatCommand() { return new ProcessChatCommand @@ -36,7 +39,8 @@ public ProcessChatCommand ToProcessChatCommand() RequestedAt = this.RequestedAt, Action = this.Action, Instruction = this.Instruction, - UserId = this.UserId + UserId = this.UserId, + UseTTS = this.UseTTS }; } } diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index 5859708..834271d 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -2,9 +2,11 @@ using ProjectVG.Application.Services.Character; using ProjectVG.Application.Services.User; using ProjectVG.Application.Services.Chat; +using ProjectVG.Application.Services.Chat.CostTracking; using ProjectVG.Application.Services.Chat.Preprocessors; using ProjectVG.Application.Services.Chat.Processors; using ProjectVG.Application.Services.Chat.Validators; +using ProjectVG.Application.Services.Chat.Handlers; using ProjectVG.Application.Services.WebSocket; using ProjectVG.Application.Services.Conversation; using ProjectVG.Application.Services.Session; @@ -27,11 +29,19 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); + + services.AddScoped(); + + // 비용 추적 데코레이터 등록 + services.AddCostTrackingDecorator("LLM_Processing"); + services.AddCostTrackingDecorator("TTS_Processing"); + services.AddCostTrackingDecorator("User_Input_Analysis"); return services; } diff --git a/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs b/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs index 410240d..d520e64 100644 --- a/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs +++ b/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs @@ -13,51 +13,6 @@ public class ChatMessageSegment public bool HasAudio => AudioData != null && AudioData.Length > 0; public bool IsEmpty => !HasText && !HasAudio; - public override string ToString() - { - var parts = new List(); - - parts.Add($"Order: {Order}"); - - if (HasText) - { - parts.Add($"Text: \"{Text}\""); - } - - if (HasAudio) - { - parts.Add($"Audio: {AudioData!.Length} bytes, {AudioContentType}, {AudioLength:F2}s"); - } - - if (!string.IsNullOrEmpty(Emotion)) - { - parts.Add($"Emotion: {Emotion}"); - } - - return $"Segment({string.Join(", ", parts)})"; - } - - public string ToShortString() - { - var parts = new List(); - - if (HasText) - { - parts.Add($"\"{Text}\""); - } - - if (HasAudio) - { - parts.Add($"[Audio: {AudioLength:F1}s]"); - } - - return string.Join(" ", parts); - } - - public string ToDebugString() - { - return $"Segment[Order={Order}, Text={HasText}, Audio={HasAudio}, Emotion={Emotion ?? "none"}, AudioSize={AudioData?.Length ?? 0} bytes, AudioLength={AudioLength:F2}s]"; - } public static ChatMessageSegment CreateTextOnly(string text, int order = 0) { @@ -67,29 +22,14 @@ public static ChatMessageSegment CreateTextOnly(string text, int order = 0) Order = order }; } - - public static ChatMessageSegment CreateAudioOnly(byte[] audioData, string contentType, float? audioLength, int order = 0) + + public void SetAudioData(byte[]? audioData, string? audioContentType, float? audioLength) { - return new ChatMessageSegment - { - AudioData = audioData, - AudioContentType = contentType, - AudioLength = audioLength, - Order = order - }; + AudioData = audioData; + AudioContentType = audioContentType; + AudioLength = audioLength; } - public static ChatMessageSegment CreateIntegrated(string text, byte[] audioData, string contentType, float? audioLength, string? emotion = null, int order = 0) - { - return new ChatMessageSegment - { - Text = text, - AudioData = audioData, - AudioContentType = contentType, - AudioLength = audioLength, - Emotion = emotion, - Order = order - }; - } + } } diff --git a/ProjectVG.Application/Models/Chat/ChatMetrics.cs b/ProjectVG.Application/Models/Chat/ChatMetrics.cs new file mode 100644 index 0000000..b353ea5 --- /dev/null +++ b/ProjectVG.Application/Models/Chat/ChatMetrics.cs @@ -0,0 +1,25 @@ +namespace ProjectVG.Application.Models.Chat +{ + public class ChatMetrics + { + public string SessionId { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; + public string CharacterId { get; set; } = string.Empty; + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public List ProcessMetrics { get; set; } = new(); + public decimal TotalCost { get; set; } + public TimeSpan TotalDuration { get; set; } + } + + public class ProcessMetrics + { + public string ProcessName { get; set; } = string.Empty; + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public TimeSpan Duration { get; set; } + public decimal Cost { get; set; } + public string? ErrorMessage { get; set; } + public Dictionary? AdditionalData { get; set; } + } +} diff --git a/ProjectVG.Application/Models/Chat/ChatOutputFormatResult.cs b/ProjectVG.Application/Models/Chat/ChatOutputFormatResult.cs deleted file mode 100644 index 3e6da41..0000000 --- a/ProjectVG.Application/Models/Chat/ChatOutputFormatResult.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ProjectVG.Application.Models.Chat -{ - public class ChatOutputFormatResult - { - public string Response { get; set; } = string.Empty; - public List Emotion { get; set; } = new List(); - public List Text { get; set; } = new List(); - } -} diff --git a/ProjectVG.Application/Models/Chat/ChatPreprocessContext.cs b/ProjectVG.Application/Models/Chat/ChatPreprocessContext.cs deleted file mode 100644 index 3458c62..0000000 --- a/ProjectVG.Application/Models/Chat/ChatPreprocessContext.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Microsoft.Extensions.Logging; -using ProjectVG.Application.Models.Character; -using ProjectVG.Domain.Entities.ConversationHistorys; -using ProjectVG.Domain.Enums; - -namespace ProjectVG.Application.Models.Chat -{ - public class ChatPreprocessContext - { - public string SessionId { get; set; } = string.Empty; - public Guid UserId { get; set; } - public Guid CharacterId { get; set; } - public string UserMessage { get; set; } = string.Empty; - - public string? Action { get; set; } - public List MemoryContext { get; set; } = new(); - public IEnumerable ConversationHistory { get; set; } = new List(); - - public string MemoryStore { get; set; } = string.Empty; - public string VoiceName { get; set; } = string.Empty; - public CharacterDto? Character { get; set; } - - public ChatPreprocessContext( - ProcessChatCommand command, - List memoryContext, - IEnumerable conversationHistory) - { - SessionId = command.SessionId; - UserId = command.UserId; - CharacterId = command.CharacterId; - UserMessage = command.Message; - MemoryStore = command.UserId.ToString(); - Action = command.Action; - MemoryContext = memoryContext ?? new List(); - ConversationHistory = conversationHistory ?? new List(); - - Character = command.Character!; - VoiceName = command.Character!.VoiceId; - } - - public List ParseConversationHistory() - { - return ConversationHistory?.Select(c => $"{c.Role}: {c.Content}").ToList() ?? new List(); - } - - public List ParseConversationHistory(int takeCount) - { - return ConversationHistory?.Take(takeCount).Select(c => $"{c.Role}: {c.Content}").ToList() ?? new List(); - } - - public override string ToString() - { - return $"ChatPreprocessContext(SessionId={SessionId}, UserId={UserId}, CharacterId={CharacterId}, UserMessage='{UserMessage}', MemoryContext.Count={MemoryContext.Count}, ConversationHistory.Count={ConversationHistory.Count()})"; - } - - public string GetDetailedInfo() - { - var info = new System.Text.StringBuilder(); - info.AppendLine("=== ChatPreprocessContext ==="); - info.AppendLine($"SessionId: {SessionId}"); - info.AppendLine($"UserId: {UserId}"); - info.AppendLine($"CharacterId: {CharacterId}"); - info.AppendLine($"Action: {Action ?? "N/A"}"); - info.AppendLine($"VoiceName: {VoiceName}"); - info.AppendLine($"MemoryStore: {MemoryStore}"); - info.AppendLine($"UserMessage: {UserMessage}"); - info.AppendLine($"Character: {(Character != null ? Character.Name : "Not Loaded")}"); - - info.AppendLine($"MemoryContext ({MemoryContext.Count} items):"); - for (int i = 0; i < MemoryContext.Count; i++) - { - info.AppendLine($" [{i}]: {MemoryContext[i]}"); - } - - info.AppendLine($"ConversationHistory ({ConversationHistory.Count()} items):"); - var parsedHistory = ParseConversationHistory(); - for (int i = 0; i < parsedHistory.Count; i++) - { - info.AppendLine($" [{i}]: {parsedHistory[i]}"); - } - info.AppendLine("=== End ChatPreprocessContext ==="); - - return info.ToString(); - } - } -} diff --git a/ProjectVG.Application/Models/Chat/ChatPreprocessResult.cs b/ProjectVG.Application/Models/Chat/ChatPreprocessResult.cs deleted file mode 100644 index 592bcb3..0000000 --- a/ProjectVG.Application/Models/Chat/ChatPreprocessResult.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace ProjectVG.Application.Models.Chat -{ - public class ChatPreprocessResult - { - public bool IsValid { get; private set; } - public ChatRequestResponse? RequestResponse { get; private set; } - public ChatPreprocessContext? Context { get; private set; } - - public static ChatPreprocessResult Success(ChatPreprocessContext context) - { - return new ChatPreprocessResult - { - IsValid = true, - Context = context - }; - } - - public static ChatPreprocessResult Failure(ChatRequestResponse requestResponse) - { - return new ChatPreprocessResult - { - IsValid = false, - RequestResponse = requestResponse - }; - } - } -} diff --git a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs new file mode 100644 index 0000000..d0900ec --- /dev/null +++ b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs @@ -0,0 +1,77 @@ +using ProjectVG.Application.Models.Character; +using ProjectVG.Domain.Entities.ConversationHistorys; + +namespace ProjectVG.Application.Models.Chat +{ + public class ChatProcessContext + { + public string SessionId { get; private set; } = string.Empty; + public Guid UserId { get; private set; } + public Guid CharacterId { get; private set; } + public string UserMessage { get; private set; } = string.Empty; + public string MemoryStore { get; private set; } = string.Empty; + public bool UseTTS { get; private set; } = true; + + public CharacterDto? Character { get; private set; } + public IEnumerable? MemoryContext { get; private set; } + public IEnumerable? ConversationHistory { get; private set; } + + public string Response { get; private set; } = string.Empty; + public double Cost { get; private set; } + public List Segments { get; private set; } = new List(); + + public string FullText => string.Join(" ", Segments.Where(s => s.HasText).Select(s => s.Text)); + public bool HasAudio => Segments.Any(s => s.HasAudio); + public bool HasText => Segments.Any(s => s.HasText); + + + public ChatProcessContext(ProcessChatCommand command) + { + SessionId = command.SessionId; + UserId = command.UserId; + CharacterId = command.CharacterId; + UserMessage = command.Message; + MemoryStore = command.UserId.ToString(); + UseTTS = command.UseTTS; + } + + public ChatProcessContext( + ProcessChatCommand command, + CharacterDto character, + IEnumerable conversationHistory, + IEnumerable memoryContext) + { + SessionId = command.SessionId; + UserId = command.UserId; + CharacterId = command.CharacterId; + UserMessage = command.Message; + MemoryStore = command.UserId.ToString(); + UseTTS = command.UseTTS; + + Character = character; + ConversationHistory = conversationHistory; + MemoryContext = memoryContext; + } + + public void SetResponse(string response, List segments, double cost) + { + Response = response; + Segments = segments; + Cost = cost; + } + + public void AddCost(double additionalCost) + { + Cost += additionalCost; + } + + public IEnumerable ParseConversationHistory(int count = 5) + { + if (ConversationHistory == null) return Enumerable.Empty(); + + return ConversationHistory + .Take(count) + .Select(h => $"{h.Role}: {h.Content}"); + } + } +} diff --git a/ProjectVG.Application/Models/Chat/ChatProcessResult.cs b/ProjectVG.Application/Models/Chat/ChatProcessResult.cs deleted file mode 100644 index 3089f51..0000000 --- a/ProjectVG.Application/Models/Chat/ChatProcessResult.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ProjectVG.Application.Models.Chat -{ - public class ChatProcessResult - { - public string Response { get; set; } = string.Empty; - public int TokensUsed { get; set; } - public double Cost { get; set; } - - public List Segments { get; set; } = new List(); - - public string FullText => string.Join(" ", Segments.Where(s => s.HasText).Select(s => s.Text)); - - public bool HasAudio => Segments.Any(s => s.HasAudio); - - public bool HasText => Segments.Any(s => s.HasText); - } -} diff --git a/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs b/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs index bb322c6..37c3e75 100644 --- a/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs +++ b/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs @@ -18,6 +18,7 @@ public string RequestId public DateTime RequestedAt { get; set; } public string? Action { get; set; } public string? Instruction { get; set; } + public bool UseTTS { get; set; } = true; public CharacterDto? Character { get; private set; } diff --git a/ProjectVG.Application/Models/Chat/UserInputAnalysis.cs b/ProjectVG.Application/Models/Chat/UserInputAnalysis.cs index 57e98fb..60fe74b 100644 --- a/ProjectVG.Application/Models/Chat/UserInputAnalysis.cs +++ b/ProjectVG.Application/Models/Chat/UserInputAnalysis.cs @@ -9,6 +9,7 @@ public class UserInputAnalysis public List Keywords { get; set; } = new List(); public string? EnhancedQuery { get; set; } public DateTime? ContextTime { get; set; } + public double Cost { get; set; } private UserInputAnalysis() { @@ -20,7 +21,8 @@ public static UserInputAnalysis CreateValid( UserInputAction action, List keywords, string? enhancedQuery = null, - DateTime? contextTime = null) + DateTime? contextTime = null, + double cost = 0) { return new UserInputAnalysis { @@ -29,7 +31,8 @@ public static UserInputAnalysis CreateValid( Action = action, Keywords = keywords, EnhancedQuery = enhancedQuery, - ContextTime = contextTime + ContextTime = contextTime, + Cost = cost }; } diff --git a/ProjectVG.Application/Services/Chat/ChatService.cs b/ProjectVG.Application/Services/Chat/ChatService.cs index 9947bc4..a9def06 100644 --- a/ProjectVG.Application/Services/Chat/ChatService.cs +++ b/ProjectVG.Application/Services/Chat/ChatService.cs @@ -1,6 +1,8 @@ using ProjectVG.Application.Services.Chat.Preprocessors; using ProjectVG.Application.Services.Chat.Processors; using ProjectVG.Application.Services.Chat.Validators; +using ProjectVG.Application.Services.Chat.CostTracking; +using ProjectVG.Application.Services.Chat.Handlers; using ProjectVG.Application.Services.Conversation; using ProjectVG.Application.Services.Character; using Microsoft.Extensions.DependencyInjection; @@ -18,12 +20,14 @@ public class ChatService : IChatService private readonly ChatRequestValidator _validator; private readonly MemoryContextPreprocessor _memoryPreprocessor; - private readonly UserInputAnalysisProcessor _inputProcessor; + private readonly ICostTrackingDecorator _inputProcessor; private readonly UserInputActionProcessor _actionProcessor; - private readonly ChatLLMProcessor _llmProcessor; - private readonly ChatTTSProcessor _ttsProcessor; + private readonly ICostTrackingDecorator _llmProcessor; + private readonly ICostTrackingDecorator _ttsProcessor; private readonly ChatResultProcessor _resultProcessor; + private readonly IChatMetricsService _metricsService; + private readonly ChatFailureHandler _failureHandler; public ChatService( IServiceScopeFactory scopeFactory, @@ -32,11 +36,13 @@ public ChatService( ICharacterService characterService, ChatRequestValidator validator, MemoryContextPreprocessor memoryPreprocessor, - UserInputAnalysisProcessor inputProcessor, + ICostTrackingDecorator inputProcessor, UserInputActionProcessor actionProcessor, - ChatLLMProcessor llmProcessor, - ChatTTSProcessor ttsProcessor, - ChatResultProcessor resultProcessor + ICostTrackingDecorator llmProcessor, + ICostTrackingDecorator ttsProcessor, + ChatResultProcessor resultProcessor, + IChatMetricsService metricsService, + ChatFailureHandler failureHandler ) { _scopeFactory = scopeFactory; _logger = logger; @@ -50,16 +56,19 @@ ChatResultProcessor resultProcessor _llmProcessor = llmProcessor; _ttsProcessor = ttsProcessor; _resultProcessor = resultProcessor; + _metricsService = metricsService; + _failureHandler = failureHandler; } public async Task EnqueueChatRequestAsync(ProcessChatCommand command) { + _metricsService.StartChatMetrics(command.SessionId, command.UserId.ToString(), command.CharacterId.ToString()); + await _validator.ValidateAsync(command); var preprocessContext = await PrepareChatRequestAsync(command); _ = Task.Run(async () => { - using var processScope = _scopeFactory.CreateScope(); await ProcessChatRequestInternalAsync(preprocessContext); }); @@ -70,11 +79,9 @@ public async Task EnqueueChatRequestAsync(ProcessChatComman /// /// 채팅 요청 준비 /// - private async Task PrepareChatRequestAsync(ProcessChatCommand command) + private async Task PrepareChatRequestAsync(ProcessChatCommand command) { var characterDto = await _characterService.GetCharacterByIdAsync(command.CharacterId); - command.SetCharacter(characterDto!); - var conversationHistory = await _conversationService.GetConversationHistoryAsync(command.UserId, command.CharacterId, 10); var inputAnalysis = await _inputProcessor.ProcessAsync(command.Message, conversationHistory); @@ -82,32 +89,30 @@ private async Task PrepareChatRequestAsync(ProcessChatCom var memoryContext = await _memoryPreprocessor.CollectMemoryContextAsync(command.UserId.ToString(), command.Message, inputAnalysis); - return new ChatPreprocessContext( - command, - memoryContext, - conversationHistory - ); + return new ChatProcessContext(command, characterDto!, conversationHistory, memoryContext); } /// /// 채팅 요청 처리 /// - private async Task ProcessChatRequestInternalAsync(ChatPreprocessContext context) + private async Task ProcessChatRequestInternalAsync(ChatProcessContext context) { try { - _logger.LogInformation("채팅 요청 처리 시작: {SessionId}", context.SessionId); - // 작업 처리 단계: LLM -> TTS -> 결과 전송 + 저장 - var llmResult = await _llmProcessor.ProcessAsync(context); - await _ttsProcessor.ProcessAsync(context, llmResult); - await _resultProcessor.SendResultsAsync(context, llmResult); - await _resultProcessor.PersistResultsAsync(context, llmResult); - - _logger.LogInformation("채팅 요청 처리 완료: {SessionId}, 토큰: {TokensUsed}", - context.SessionId, llmResult.TokensUsed); + await _llmProcessor.ProcessAsync(context); + await _ttsProcessor.ProcessAsync(context); + + using var scope = _scopeFactory.CreateScope(); + var resultProcessor = scope.ServiceProvider.GetRequiredService(); + await resultProcessor.SendResultsAsync(context); + await resultProcessor.PersistResultsAsync(context); } catch (Exception ex) { - _logger.LogError(ex, "채팅 요청 처리 중 오류 발생: {SessionId}", context.SessionId); + await _failureHandler.HandleFailureAsync(context, ex); + } + finally { + _metricsService.EndChatMetrics(); + _metricsService.LogChatMetrics(); } } } diff --git a/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs b/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs new file mode 100644 index 0000000..0dcc4f5 --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs @@ -0,0 +1,94 @@ +using ProjectVG.Application.Models.Chat; +using ProjectVG.Application.Services.Chat.CostTracking; + +namespace ProjectVG.Application.Services.Chat.CostTracking +{ + public class ChatMetricsService : IChatMetricsService + { + private readonly ILogger _logger; + private readonly AsyncLocal _currentMetrics = new(); + + public ChatMetricsService(ILogger logger) + { + _logger = logger; + } + + public void StartChatMetrics(string sessionId, string userId, string characterId) + { + _currentMetrics.Value = new ChatMetrics + { + SessionId = sessionId, + UserId = userId, + CharacterId = characterId, + StartTime = DateTime.UtcNow + }; + Console.WriteLine($"[METRICS] 채팅 메트릭 시작: {sessionId}"); + } + + public void StartProcessMetrics(string processName) + { + if (_currentMetrics.Value == null) return; + + var processMetrics = new ProcessMetrics + { + ProcessName = processName, + StartTime = DateTime.UtcNow + }; + + _currentMetrics.Value.ProcessMetrics.Add(processMetrics); + Console.WriteLine($"[METRICS] 프로세스 시작: {processName}"); + } + + public void EndProcessMetrics(string processName, decimal cost = 0, string? errorMessage = null, Dictionary? additionalData = null) + { + if (_currentMetrics.Value == null) return; + + var processMetrics = _currentMetrics.Value.ProcessMetrics + .FirstOrDefault(p => p.ProcessName == processName && p.EndTime == default); + + if (processMetrics != null) + { + processMetrics.EndTime = DateTime.UtcNow; + processMetrics.Duration = processMetrics.EndTime - processMetrics.StartTime; + processMetrics.Cost = cost; + processMetrics.ErrorMessage = errorMessage; + processMetrics.AdditionalData = additionalData; + } + } + + public void EndChatMetrics() + { + if (_currentMetrics.Value == null) return; + + _currentMetrics.Value.EndTime = DateTime.UtcNow; + _currentMetrics.Value.TotalDuration = _currentMetrics.Value.EndTime - _currentMetrics.Value.StartTime; + _currentMetrics.Value.TotalCost = _currentMetrics.Value.ProcessMetrics.Sum(p => p.Cost); + } + + public ChatMetrics? GetCurrentChatMetrics() + { + return _currentMetrics.Value; + } + + public void LogChatMetrics() + { + var metrics = _currentMetrics.Value; + if (metrics == null) return; + + Console.WriteLine($"[METRICS] 채팅 메트릭 로그 시작: {metrics.SessionId}"); + + var totalCostInDollars = (double)metrics.TotalCost / 100_000.0; + _logger.LogInformation( + "채팅 메트릭 - SessionId: {SessionId}, 총 비용: ${TotalCost:F6}, 총 시간: {TotalDuration}", + metrics.SessionId, totalCostInDollars, metrics.TotalDuration); + + foreach (var process in metrics.ProcessMetrics) + { + var processCostInDollars = (double)process.Cost / 100_000.0; + _logger.LogInformation( + " - {ProcessName}: {Duration}ms, 비용: ${Cost:F6}", + process.ProcessName, process.Duration.TotalMilliseconds, processCostInDollars); + } + } + } +} diff --git a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs new file mode 100644 index 0000000..b2e7779 --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs @@ -0,0 +1,114 @@ +using ProjectVG.Application.Models.Chat; +using ProjectVG.Application.Services.Chat.CostTracking; +using ProjectVG.Domain.Entities.ConversationHistorys; + +namespace ProjectVG.Application.Services.Chat.CostTracking +{ + public class CostTrackingDecorator : ICostTrackingDecorator where T : class + { + private readonly T _service; + private readonly IChatMetricsService _metricsService; + private readonly string _processName; + + public CostTrackingDecorator(T service, IChatMetricsService metricsService, string processName) + { + _service = service; + _metricsService = metricsService; + _processName = processName; + } + + public T Service => _service; + + private decimal ExtractCost(object? result) + { + if (result == null) return 0; + + var resultType = result.GetType(); + + // Cost 속성이 있는 경우 + var costProperty = resultType.GetProperty("Cost"); + if (costProperty != null) + { + var costValue = costProperty.GetValue(result); + if (costValue != null) + { + // double, decimal, int 등 다양한 타입 지원 + return Convert.ToDecimal(costValue); + } + } + + return 0; + } + + public async Task ProcessAsync(ChatProcessContext context) + { + _metricsService.StartProcessMetrics(_processName); + + try + { + // 리플렉션으로 ProcessAsync 메서드 호출 + var method = typeof(T).GetMethod("ProcessAsync", new[] { typeof(ChatProcessContext) }); + + if (method == null) + throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); + + var invokeResult = method.Invoke(_service, new object[] { context }); + if (invokeResult == null) + throw new InvalidOperationException($"ProcessAsync 메서드 호출 결과가 null입니다: {typeof(T).Name}"); + + if (invokeResult is not Task taskResult) + throw new InvalidOperationException($"ProcessAsync 메서드 반환 타입이 올바르지 않습니다: {typeof(T).Name}"); + + await taskResult; + + // Cost 값만 직접 추출 + var cost = ExtractCost(context); + Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: {cost:F0} Cost"); + Console.WriteLine($"[COST_TRACKING] {_processName} - 컨텍스트 타입: {context?.GetType().Name}, Cost 속성 값: {context?.GetType().GetProperty("Cost")?.GetValue(context)}"); + _metricsService.EndProcessMetrics(_processName, cost); + } + catch (Exception ex) + { + _metricsService.EndProcessMetrics(_processName, 0, ex.Message); + throw; + } + } + + + + public async Task ProcessAsync(string userInput, IEnumerable conversationHistory) + { + _metricsService.StartProcessMetrics(_processName); + + try + { + // 리플렉션으로 ProcessAsync 메서드 호출 + var method = typeof(T).GetMethod("ProcessAsync", new[] { typeof(string), typeof(IEnumerable) }); + + if (method == null) + throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); + + var invokeResult = method.Invoke(_service, new object[] { userInput, conversationHistory }); + if (invokeResult == null) + throw new InvalidOperationException($"ProcessAsync 메서드 호출 결과가 null입니다: {typeof(T).Name}"); + + if (invokeResult is not Task taskResult) + throw new InvalidOperationException($"ProcessAsync 메서드 반환 타입이 올바르지 않습니다: {typeof(T).Name}"); + + var result = await taskResult!; + + // Cost 값만 직접 추출 + var cost = ExtractCost(result); + Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: {cost:F0} Cost"); + Console.WriteLine($"[COST_TRACKING] {_processName} - 원본 결과 타입: {result?.GetType().Name}, Cost 속성 값: {result?.GetType().GetProperty("Cost")?.GetValue(result)}"); + _metricsService.EndProcessMetrics(_processName, cost); + return result; + } + catch (Exception ex) + { + _metricsService.EndProcessMetrics(_processName, 0, ex.Message); + throw; + } + } + } +} diff --git a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecoratorFactory.cs b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecoratorFactory.cs new file mode 100644 index 0000000..ed152b6 --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecoratorFactory.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using ProjectVG.Application.Models.Chat; +using ProjectVG.Application.Services.Chat.CostTracking; + +namespace ProjectVG.Application.Services.Chat.CostTracking +{ + public static class CostTrackingDecoratorFactory + { + public static IServiceCollection AddCostTrackingDecorator( + this IServiceCollection services, + string processName, + ServiceLifetime lifetime = ServiceLifetime.Scoped) + where T : class + { + // 원본 서비스 등록 + services.Add(new ServiceDescriptor(typeof(T), typeof(T), lifetime)); + + // 비용 추적 데코레이터 등록 + services.Add(new ServiceDescriptor( + typeof(ICostTrackingDecorator), + provider => + { + var service = provider.GetRequiredService(); + var metricsService = provider.GetRequiredService(); + return new CostTrackingDecorator(service, metricsService, processName); + }, + lifetime)); + + return services; + } + } +} diff --git a/ProjectVG.Application/Services/Chat/CostTracking/IChatMetricsService.cs b/ProjectVG.Application/Services/Chat/CostTracking/IChatMetricsService.cs new file mode 100644 index 0000000..67c9e8d --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/IChatMetricsService.cs @@ -0,0 +1,14 @@ +using ProjectVG.Application.Models.Chat; + +namespace ProjectVG.Application.Services.Chat.CostTracking +{ + public interface IChatMetricsService + { + void StartChatMetrics(string sessionId, string userId, string characterId); + void StartProcessMetrics(string processName); + void EndProcessMetrics(string processName, decimal cost = 0, string? errorMessage = null, Dictionary? additionalData = null); + void EndChatMetrics(); + ChatMetrics? GetCurrentChatMetrics(); + void LogChatMetrics(); + } +} diff --git a/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs b/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs new file mode 100644 index 0000000..61b1e66 --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs @@ -0,0 +1,12 @@ +using ProjectVG.Application.Models.Chat; +using ProjectVG.Domain.Entities.ConversationHistorys; + +namespace ProjectVG.Application.Services.Chat.CostTracking +{ + public interface ICostTrackingDecorator where T : class + { + T Service { get; } + Task ProcessAsync(ChatProcessContext context); + Task ProcessAsync(string userInput, IEnumerable conversationHistory); + } +} diff --git a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs index 207b267..1a390d7 100644 --- a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs @@ -5,13 +5,13 @@ namespace ProjectVG.Application.Services.Chat.Factories { - public class ChatLLMFormat : ILLMFormat + public class ChatLLMFormat : ILLMFormat> { public ChatLLMFormat() { } - public string GetSystemMessage(ChatPreprocessContext input) + public string GetSystemMessage(ChatProcessContext input) { var character = input.Character ?? throw new InvalidOperationException("캐릭터 정보가 로드되지 않았습니다."); @@ -25,11 +25,10 @@ public string GetSystemMessage(ChatPreprocessContext input) return sb.ToString(); } - public string GetInstructions(ChatPreprocessContext input) + public string GetInstructions(ChatProcessContext input) { var sb = new StringBuilder(); - // 메모리 컨텍스트 추가 if (input.MemoryContext?.Any() == true) { sb.AppendLine("관련 기억:"); @@ -40,7 +39,6 @@ public string GetInstructions(ChatPreprocessContext input) sb.AppendLine(); } - // 대화 기록 추가 var conversationHistory = input.ParseConversationHistory(5); if (conversationHistory.Any()) { @@ -52,7 +50,6 @@ public string GetInstructions(ChatPreprocessContext input) sb.AppendLine(); } - // 출력 포맷 지침 추가 sb.AppendLine(GetFormatInstructions()); return sb.ToString(); @@ -62,18 +59,14 @@ public string GetInstructions(ChatPreprocessContext input) public float Temperature => 0.7f; public int MaxTokens => 1000; - public ChatOutputFormatResult Parse(string llmResponse, ChatPreprocessContext input) + public List Parse(string llmResponse, ChatProcessContext input) { - return ParseChatResponse(llmResponse, input.VoiceName); + return ParseChatResponseToSegments(llmResponse, input.Character?.VoiceId); } - public double CalculateCost(int tokensUsed) + public double CalculateCost(int promptTokens, int completionTokens) { - var inputCost = LLMModelInfo.GetInputCost(Model); - var outputCost = LLMModelInfo.GetOutputCost(Model); - - // 토큰 수를 백만 단위로 변환하여 비용 계산 - return (tokensUsed / 1_000_000.0) * (inputCost + outputCost); + return LLMModelInfo.CalculateCost(Model, promptTokens, completionTokens); } private string GetFormatInstructions() @@ -89,54 +82,63 @@ [neutral] 내가 그런다고 좋아할 것 같아? [shy] 하지만 츄 해준 "; } - private ChatOutputFormatResult ParseChatResponse(string llmText, string? voiceName = null) + private List ParseChatResponseToSegments(string llmText, string? voiceId = null) { if (string.IsNullOrWhiteSpace(llmText)) - return new ChatOutputFormatResult(); + return new List(); string response = llmText.Trim(); - var emotions = new List(); - var texts = new List(); + var segments = new List(); + var seenTexts = new HashSet(StringComparer.OrdinalIgnoreCase); - // [감정] 답변 패턴 추출 var matches = Regex.Matches(response, @"\[(.*?)\]\s*([^\[]+)"); - - // 보이스별 감정 매핑 - Dictionary? emotionMap = null; - if (!string.IsNullOrWhiteSpace(voiceName)) - { - var profile = VoiceCatalog.GetProfile(voiceName); - if (profile != null && profile.EmotionMap != null) - emotionMap = profile.EmotionMap; - } + var emotionMap = GetEmotionMap(voiceId); if (matches.Count > 0) { - foreach (Match match in matches) - { - if (match.Groups.Count >= 3) - { - var originalEmotion = match.Groups[1].Value.Trim(); - var mappedEmotion = emotionMap != null && emotionMap.ContainsKey(originalEmotion) - ? emotionMap[originalEmotion] - : originalEmotion; - emotions.Add(mappedEmotion); - texts.Add(match.Groups[2].Value.Trim()); - } - } + ProcessMatches(matches, emotionMap, segments, seenTexts); } else { - emotions.Add("neutral"); - texts.Add(response); + var segment = ChatMessageSegment.CreateTextOnly(response, 0); + segment.Emotion = "neutral"; + segments.Add(segment); } - return new ChatOutputFormatResult + return segments; + } + + private Dictionary? GetEmotionMap(string? voiceId) + { + if (string.IsNullOrWhiteSpace(voiceId)) + return null; + + var profile = VoiceCatalog.GetProfileById(voiceId); + return profile?.EmotionMap; + } + + private void ProcessMatches(MatchCollection matches, Dictionary? emotionMap, List segments, HashSet seenTexts) + { + for (int i = 0; i < matches.Count; i++) { - Response = response, - Emotion = emotions, - Text = texts - }; + var match = matches[i]; + if (match.Groups.Count >= 3) + { + var originalEmotion = match.Groups[1].Value.Trim(); + var mappedEmotion = emotionMap != null && emotionMap.ContainsKey(originalEmotion) + ? emotionMap[originalEmotion] + : originalEmotion; + var text = match.Groups[2].Value.Trim(); + + if (!seenTexts.Contains(text)) + { + seenTexts.Add(text); + var segment = ChatMessageSegment.CreateTextOnly(text, segments.Count); + segment.Emotion = mappedEmotion; + segments.Add(segment); + } + } + } } } } diff --git a/ProjectVG.Application/Services/Chat/Factories/ILLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/ILLMFormat.cs index 216aa65..3afe36c 100644 --- a/ProjectVG.Application/Services/Chat/Factories/ILLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/ILLMFormat.cs @@ -8,6 +8,6 @@ public interface ILLMFormat float Temperature { get; } int MaxTokens { get; } TOutput Parse(string llmResponse, TInput input); - double CalculateCost(int tokensUsed); + double CalculateCost(int promptTokens, int completionTokens); } } diff --git a/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs index bb5af41..3590803 100644 --- a/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs @@ -204,13 +204,10 @@ private UserInputAnalysis CreateDefaultValidResponse() return UserInputAnalysis.CreateValid("일반적인 대화", "대화", UserInputAction.Chat, new List()); } - public double CalculateCost(int tokensUsed) + public double CalculateCost(int promptTokens, int completionTokens) { - var inputCost = LLMModelInfo.GetInputCost(Model); - var outputCost = LLMModelInfo.GetOutputCost(Model); - - // 토큰 수를 백만 단위로 변환하여 비용 계산 - return (tokensUsed / 1_000_000.0) * (inputCost + outputCost); + return LLMModelInfo.CalculateCost(Model, promptTokens, completionTokens); } + } } diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs new file mode 100644 index 0000000..d02d181 --- /dev/null +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs @@ -0,0 +1,48 @@ +using ProjectVG.Application.Models.Chat; +using ProjectVG.Application.Models.WebSocket; +using ProjectVG.Application.Services.WebSocket; +using ProjectVG.Application.Services.Conversation; +using ProjectVG.Infrastructure.Integrations.MemoryClient; +using ProjectVG.Domain.Enums; + +namespace ProjectVG.Application.Services.Chat.Handlers +{ + public class ChatFailureHandler + { + private readonly ILogger _logger; + private readonly IWebSocketManager _webSocketService; + private readonly IConversationService _conversationService; + private readonly IMemoryClient _memoryClient; + + public ChatFailureHandler( + ILogger logger, + IWebSocketManager webSocketService, + IConversationService conversationService, + IMemoryClient memoryClient) + { + _logger = logger; + _webSocketService = webSocketService; + _conversationService = conversationService; + _memoryClient = memoryClient; + } + + public Task HandleFailureAsync(ChatProcessContext context, Exception exception) + { + _logger.LogError(exception, "채팅 처리 실패: 세션 {SessionId}", context.SessionId); + return SendErrorMessageAsync(context, "요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + + private async Task SendErrorMessageAsync(ChatProcessContext context, string errorMessage) + { + try + { + var errorResponse = new WebSocketMessage("error", new { message = errorMessage }); + await _webSocketService.SendAsync(context.SessionId, errorResponse); + } + catch (Exception ex) + { + _logger.LogError(ex, "오류 메시지 전송 실패: 세션 {SessionId}", context.SessionId); + } + } + } +} diff --git a/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs b/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs index 7266d33..adb69d6 100644 --- a/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs @@ -41,10 +41,13 @@ public async Task ProcessAsync(string userInput, IEnumerable< temperature: format.Temperature ); + var cost = format.CalculateCost(llmResponse.InputTokens, llmResponse.OutputTokens); var analysis = format.Parse(llmResponse.Response, userInput); + analysis.Cost = cost; - _logger.LogDebug("사용자 입력 분석 완료: '{Input}' -> 맥락: {Context}, 의도: {Intent}, 액션: {Action}", - userInput, analysis.ConversationContext, analysis.UserIntent, analysis.Action); + Console.WriteLine($"[USER_INPUT_ANALYSIS_DEBUG] ID: {llmResponse.Id}, 입력 토큰: {llmResponse.InputTokens}, 출력 토큰: {llmResponse.OutputTokens}, 총 토큰: {llmResponse.TokensUsed}, 계산된 비용: {cost:F0} Cost"); + _logger.LogDebug("사용자 입력 분석 완료: '{Input}' -> 맥락: {Context}, 의도: {Intent}, 액션: {Action}, 비용: {Cost}", + userInput, analysis.ConversationContext, analysis.UserIntent, analysis.Action, cost); return analysis; } diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs index a66c832..009879c 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs @@ -17,7 +17,7 @@ public ChatLLMProcessor( _logger = logger; } - public async Task ProcessAsync(ChatPreprocessContext context) + public async Task ProcessAsync(ChatProcessContext context) { var format = LLMFormatFactory.CreateChatFormat(); @@ -25,39 +25,21 @@ public async Task ProcessAsync(ChatPreprocessContext context) format.GetSystemMessage(context), context.UserMessage, format.GetInstructions(context), - context.ParseConversationHistory(), - context.MemoryContext, + context.ParseConversationHistory().ToList(), + context.MemoryContext?.ToList(), model: format.Model, maxTokens: format.MaxTokens, temperature: format.Temperature ); - // 결과 파싱 - var parsed = format.Parse(llmResponse.Response, context); - var cost = format.CalculateCost(llmResponse.TokensUsed); - var segments = CreateSegments(parsed); + var segments = format.Parse(llmResponse.Response, context); + var cost = format.CalculateCost(llmResponse.InputTokens, llmResponse.OutputTokens); - _logger.LogDebug("LLM 처리 완료: 세션 {SessionId}, 토큰 {TokensUsed}, 비용 {Cost}", - context.SessionId, llmResponse.TokensUsed, cost); + Console.WriteLine($"[LLM_DEBUG] ID: {llmResponse.Id}, 입력 토큰: {llmResponse.InputTokens}, 출력 토큰: {llmResponse.OutputTokens}, 계산된 비용: {cost:F0} Cost"); + _logger.LogDebug("LLM 처리 완료: 세션 {SessionId}, ID {Id}, 입력 토큰 {InputTokens}, 출력 토큰 {OutputTokens}, 비용 {Cost}", + context.SessionId, llmResponse.Id, llmResponse.InputTokens, llmResponse.OutputTokens, cost); - return new ChatProcessResult { - Response = parsed.Response, - Segments = segments, - TokensUsed = llmResponse.TokensUsed, - Cost = cost - }; - } - - private List CreateSegments(ChatOutputFormatResult parsed) - { - var segments = new List(); - for (int i = 0; i < parsed.Text.Count; i++) { - var emotion = parsed.Emotion.Count > i ? parsed.Emotion[i] : "neutral"; - var segment = ChatMessageSegment.CreateTextOnly(parsed.Text[i], i); - segment.Emotion = emotion; - segments.Add(segment); - } - return segments; + context.SetResponse(llmResponse.Response, segments, cost); } } } diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs index 861eeb3..53fe599 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs @@ -26,18 +26,18 @@ public ChatResultProcessor( _webSocketService = webSocketService; } - public async Task PersistResultsAsync(ChatPreprocessContext context, ChatProcessResult result) + public async Task PersistResultsAsync(ChatProcessContext context) { await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.User, context.UserMessage); - await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.Assistant, result.Response); - await _memoryClient.AddMemoryAsync(context.MemoryStore, result.Response); + await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.Assistant, context.Response); + await _memoryClient.AddMemoryAsync(context.MemoryStore, context.Response); _logger.LogDebug("채팅 결과 저장 완료: 세션 {SessionId}, 사용자 {UserId}", context.SessionId, context.UserId); } - public async Task SendResultsAsync(ChatPreprocessContext context, ChatProcessResult result) + public async Task SendResultsAsync(ChatProcessContext context) { - foreach (var segment in result.Segments.OrderBy(s => s.Order)) { + foreach (var segment in context.Segments.OrderBy(s => s.Order)) { if (segment.IsEmpty) continue; var integratedMessage = new IntegratedChatMessage { @@ -55,7 +55,7 @@ public async Task SendResultsAsync(ChatPreprocessContext context, ChatProcessRes } _logger.LogDebug("채팅 결과 전송 완료: 세션 {SessionId}, 세그먼트 {SegmentCount}개", - context.SessionId, result.Segments.Count(s => !s.IsEmpty)); + context.SessionId, context.Segments.Count(s => !s.IsEmpty)); } } } diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs index 3ccf9de..b758d20 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs @@ -17,172 +17,98 @@ public ChatTTSProcessor( _logger = logger; } - public async Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult result) + public async Task ProcessAsync(ChatProcessContext context) { - if (ShouldSkipProcessing(context, result)) { + if (!context.UseTTS || string.IsNullOrWhiteSpace(context.Character?.VoiceId) || context.Segments?.Count == 0) { + _logger.LogDebug("TTS 처리 건너뜀: 세션 {SessionId}, TTS사용여부 {UseTTS}, 음성ID {VoiceId}, 세그먼트 수 {SegmentCount}", + context.SessionId, context.UseTTS, context.Character?.VoiceId, context.Segments?.Count ?? 0); return; } - var profile = GetVoiceProfile(context.VoiceName, context.SessionId); + var profile = VoiceCatalog.GetProfile(context.Character.VoiceId); if (profile == null) { + _logger.LogWarning("존재하지 않는 보이스: {VoiceId}, 세션 {SessionId}", context.Character.VoiceId, context.SessionId); return; } - var ttsTasks = CreateTTSProcessingTasks(context, result, profile); - var ttsResults = await Task.WhenAll(ttsTasks); - - ApplyTTSResultsToSegments(ttsResults, result); - - LogProcessingCompletion(context.SessionId, ttsResults, result.Cost); - } - - private bool ShouldSkipProcessing(ChatPreprocessContext context, ChatProcessResult result) - { - if (string.IsNullOrWhiteSpace(context.VoiceName) || result.Segments?.Count == 0) { - _logger.LogDebug("TTS 처리 건너뜀: 세션 {SessionId}, 음성명 {VoiceName}, 세그먼트 수 {SegmentCount}", - context.SessionId, context.VoiceName, result.Segments?.Count ?? 0); - return true; - } - return false; - } - - private VoiceProfile? GetVoiceProfile(string voiceName, string sessionId) - { - var profile = VoiceCatalog.GetProfile(voiceName); - if (profile == null) { - _logger.LogWarning("존재하지 않는 보이스: {VoiceName}, 세션 {SessionId}", voiceName, sessionId); - } - return profile; - } - - private List> CreateTTSProcessingTasks( - ChatPreprocessContext context, - ChatProcessResult result, - VoiceProfile profile) - { var ttsTasks = new List>(); + for (int i = 0; i < context.Segments.Count; i++) { + var segment = context.Segments[i]; + if (!segment.HasText) continue; - for (int i = 0; i < result.Segments.Count; i++) { + var emotion = NormalizeEmotion(segment.Emotion, profile); int idx = i; - var segment = result.Segments[idx]; + ttsTasks.Add(Task.Run(async () => (idx, await GenerateTTSAsync(profile, segment.Text!, emotion)))); + } - if (!segment.HasText) continue; + var ttsResults = await Task.WhenAll(ttsTasks); + var processedCount = 0; - var emotion = ValidateAndNormalizeEmotion(segment.Emotion, profile, context.VoiceName); - ttsTasks.Add(Task.Run(async () => (idx, await ProcessTTSAsync(profile, segment.Text!, emotion)))); + foreach (var (idx, ttsResult) in ttsResults.OrderBy(x => x.idx)) { + if (ttsResult.Success == true && ttsResult.AudioData != null) { + var segment = context.Segments[idx]; + segment.SetAudioData(ttsResult.AudioData, ttsResult.ContentType, ttsResult.AudioLength); + + if (ttsResult.AudioLength.HasValue) { + var ttsCost = TTSCostInfo.CalculateTTSCost(ttsResult.AudioLength.Value); + context.AddCost(ttsCost); + Console.WriteLine($"[TTS_DEBUG] 오디오 길이: {ttsResult.AudioLength.Value:F2}초, TTS 비용: {ttsCost:F0} Cost"); + } + processedCount++; + } } - return ttsTasks; + _logger.LogDebug("TTS 처리 완료: 세션 {SessionId}, 처리된 세그먼트 {ProcessedCount}개, 총 비용 {TotalCost}", + context.SessionId, processedCount, context.Cost); } - private string ValidateAndNormalizeEmotion(string? emotion, VoiceProfile profile, string voiceName) + private string NormalizeEmotion(string? emotion, VoiceProfile profile) { var normalizedEmotion = emotion ?? "neutral"; - if (!profile.SupportedStyles.Contains(normalizedEmotion)) { - _logger.LogWarning("보이스 '{VoiceName}'는 '{Emotion}' 스타일을 지원하지 않습니다. 기본값 사용.", - voiceName, normalizedEmotion); + _logger.LogWarning("보이스 '{VoiceId}'는 '{Emotion}' 스타일을 지원하지 않습니다. 기본값 사용.", + profile.VoiceId, normalizedEmotion); return "neutral"; } - return normalizedEmotion; } - private void ApplyTTSResultsToSegments( - (int idx, TextToSpeechResponse)[] ttsResults, - ChatProcessResult result) - { - foreach (var (idx, ttsResult) in ttsResults.OrderBy(x => x.idx)) { - if (IsValidTTSResult(ttsResult)) { - ApplyTTSResultToSegment(result.Segments[idx], ttsResult); - UpdateCost(result, ttsResult); - } - } - } - - private bool IsValidTTSResult(TextToSpeechResponse ttsResult) - { - return ttsResult.Success == true && ttsResult.AudioData != null; - } - - private void ApplyTTSResultToSegment(ChatMessageSegment segment, TextToSpeechResponse ttsResult) - { - segment.AudioData = ttsResult.AudioData; - segment.AudioContentType = ttsResult.ContentType; - segment.AudioLength = ttsResult.AudioLength; - } - - private void UpdateCost(ChatProcessResult result, TextToSpeechResponse ttsResult) - { - if (ttsResult.AudioLength.HasValue) { - result.Cost += Math.Ceiling(ttsResult.AudioLength.Value / 0.1); - } - } - - private void LogProcessingCompletion(string sessionId, (int idx, TextToSpeechResponse)[] ttsResults, double totalCost) - { - var processedCount = ttsResults.Count(r => r.Item2.Success); - _logger.LogDebug("TTS 처리 완료: 세션 {SessionId}, 처리된 세그먼트 {ProcessedCount}개, 총 비용 {TotalCost}", - sessionId, processedCount, totalCost); - } - - private async Task ProcessTTSAsync(VoiceProfile profile, string text, string emotion) + private async Task GenerateTTSAsync(VoiceProfile profile, string text, string emotion) { var startTime = DateTime.UtcNow; try { - ValidateText(text); - var request = CreateTTSRequest(profile, text, emotion); + if (string.IsNullOrWhiteSpace(text)) + throw new ValidationException(ErrorCode.MESSAGE_EMPTY, "텍스트가 비어있습니다."); + if (text.Length > 300) + throw new ValidationException(ErrorCode.MESSAGE_TOO_LONG, "텍스트는 300자를 초과할 수 없습니다."); + + var request = new TextToSpeechRequest { + Text = text, + Language = profile.DefaultLanguage, + Emotion = emotion, + VoiceSettings = new VoiceSettings(), + VoiceId = profile.VoiceId + }; + var response = await _ttsClient.TextToSpeechAsync(request); - LogTTSProcessingTime(startTime, response.AudioLength); + var endTime = DateTime.UtcNow; + var processingTime = (endTime - startTime).TotalMilliseconds; + _logger.LogInformation("[TTS] 응답 생성 완료: 오디오 길이 ({AudioLength:F2}초), 요청 시간({ProcessingTimeMs:F2}ms)", + response.AudioLength, processingTime); + return response; } catch (ValidationException vex) { _logger.LogWarning(vex, "[TTS] 요청 검증 실패"); - return CreateErrorResponse(vex.Message); + return new TextToSpeechResponse { Success = false, ErrorMessage = vex.Message }; } catch (Exception ex) { _logger.LogError(ex, "[TTS] TTS 서비스 오류 발생"); - return CreateErrorResponse(ex.Message); + return new TextToSpeechResponse { Success = false, ErrorMessage = ex.Message }; } } - - private TextToSpeechRequest CreateTTSRequest(VoiceProfile profile, string text, string emotion) - { - return new TextToSpeechRequest { - Text = text, - Language = profile.DefaultLanguage, - Emotion = emotion, - VoiceSettings = new VoiceSettings(), - VoiceId = profile.VoiceId - }; - } - - private void LogTTSProcessingTime(DateTime startTime, double? audioLength) - { - var endTime = DateTime.UtcNow; - var processingTime = (endTime - startTime).TotalMilliseconds; - - _logger.LogInformation("[TTS] 응답 생성 완료: 오디오 길이 ({AudioLength:F2}초), 요청 시간({ProcessingTimeMs:F2}ms)", - audioLength, processingTime); - } - - private TextToSpeechResponse CreateErrorResponse(string errorMessage) - { - return new TextToSpeechResponse { - Success = false, - ErrorMessage = errorMessage - }; - } - - private void ValidateText(string text) - { - if (string.IsNullOrWhiteSpace(text)) - throw new ValidationException(ErrorCode.MESSAGE_EMPTY, "텍스트가 비어있습니다."); - if (text.Length > 300) - throw new ValidationException(ErrorCode.MESSAGE_TOO_LONG, "텍스트는 300자를 초과할 수 없습니다."); - } } } diff --git a/ProjectVG.Common/Constants/LLMModelInfo.cs b/ProjectVG.Common/Constants/LLMModelInfo.cs index b97d9b4..18f0b0f 100644 --- a/ProjectVG.Common/Constants/LLMModelInfo.cs +++ b/ProjectVG.Common/Constants/LLMModelInfo.cs @@ -2,6 +2,9 @@ namespace ProjectVG.Common.Constants { public static class LLMModelInfo { + private const double TOKENS_PER_MILLION = 1_000_000.0; + private const double MILLICENTS_PER_DOLLAR = 100_000.0; + private const double COST_CALCULATION_FACTOR = TOKENS_PER_MILLION / MILLICENTS_PER_DOLLAR; public static class GPT5 { public const string Name = "gpt-5"; @@ -246,7 +249,7 @@ public static double GetInputCost(string model) O3.Name => O3.Price.Input, GPT35Turbo.Name => GPT35Turbo.Price.Input, GPT4.Name => GPT4.Price.Input, - _ => GPT4oMini.Price.Input // 기본값 + _ => GPT4oMini.Price.Input }; } @@ -274,10 +277,20 @@ public static double GetOutputCost(string model) O3.Name => O3.Price.Output, GPT35Turbo.Name => GPT35Turbo.Price.Output, GPT4.Name => GPT4.Price.Output, - _ => GPT4oMini.Price.Output // 기본값 + _ => GPT4oMini.Price.Output }; } + public static double GetInputCostPerToken(string model) + { + return GetInputCost(model) / COST_CALCULATION_FACTOR; + } + + public static double GetOutputCostPerToken(string model) + { + return GetOutputCost(model) / COST_CALCULATION_FACTOR; + } + public static double GetCachedInputCost(string model) { return model switch @@ -295,8 +308,19 @@ public static double GetCachedInputCost(string model) GPT4oMiniRealtimePreview.Name => GPT4oMiniRealtimePreview.Price.CachedInput, O1.Name => O1.Price.CachedInput, O3.Name => O3.Price.CachedInput, - _ => GetInputCost(model) * 0.1 // 기본적으로 입력 비용의 10% + _ => GetInputCost(model) * 0.1 }; } + + public static double CalculateCost(string model, int promptTokens, int completionTokens) + { + var inputCostPerToken = GetInputCostPerToken(model); + var outputCostPerToken = GetOutputCostPerToken(model); + + var inputCostTotal = Math.Ceiling(promptTokens * inputCostPerToken); + var outputCostTotal = Math.Ceiling(completionTokens * outputCostPerToken); + + return inputCostTotal + outputCostTotal; + } } } diff --git a/ProjectVG.Common/Constants/TTSCostInfo.cs b/ProjectVG.Common/Constants/TTSCostInfo.cs new file mode 100644 index 0000000..0000e19 --- /dev/null +++ b/ProjectVG.Common/Constants/TTSCostInfo.cs @@ -0,0 +1,21 @@ +namespace ProjectVG.Common.Constants +{ + public static class TTSCostInfo + { + private const double TTS_CREDITS_PER_DOLLAR = 100_000.0; + private const double TTS_CREDITS_PER_SECOND = 10.0; + private const double MILLICENTS_PER_DOLLAR = 100_000.0; + private const double TTS_COST_PER_SECOND = TTS_CREDITS_PER_SECOND / TTS_CREDITS_PER_DOLLAR * MILLICENTS_PER_DOLLAR; + + public static double GetTTSCostPerSecond() + { + return TTS_COST_PER_SECOND; + } + + public static double CalculateTTSCost(double durationInSeconds) + { + var roundedDuration = Math.Ceiling(durationInSeconds * 10) / 10.0; + return Math.Ceiling(roundedDuration * TTS_COST_PER_SECOND); + } + } +} diff --git a/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs b/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs index d731729..e71e281 100644 --- a/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs +++ b/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs @@ -79,8 +79,11 @@ public async Task SendRequestAsync(LLMRequest request) return new LLMResponse { Success = true, + Id = "mock-chatcmpl-" + Guid.NewGuid().ToString("N")[..8], Response = "안녕하세요! 저는 현재 Mock 모드로 동작하고 있습니다. 실제 LLM 서비스가 연결되지 않았습니다.", TokensUsed = 50, + InputTokens = 30, + OutputTokens = 20, ResponseTime = 100 }; } diff --git a/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs b/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs index c2aa3ee..9740499 100644 --- a/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs +++ b/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs @@ -4,6 +4,12 @@ namespace ProjectVG.Infrastructure.Integrations.LLMClient.Models { public class LLMResponse { + /// + /// OpenAI API 응답 ID + /// + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + /// /// 세션 ID /// @@ -17,11 +23,23 @@ public class LLMResponse public string Response { get; set; } = default!; /// - /// 사용된 토큰 수 + /// 사용된 토큰 수 (총합) /// [JsonPropertyName("total_tokens_used")] public int TokensUsed { get; set; } + /// + /// 입력 토큰 수 (input_tokens) + /// + [JsonPropertyName("input_tokens")] + public int InputTokens { get; set; } + + /// + /// 출력 토큰 수 (output_tokens) + /// + [JsonPropertyName("output_tokens")] + public int OutputTokens { get; set; } + /// /// 처리 시간 (ms) ///