From 0a811dac39de4a6e92bcbf845d8a7fd6856bb977 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sat, 16 Aug 2025 17:13:39 +0900 Subject: [PATCH 1/9] fix: dbcontext scope --- ProjectVG.Application/Services/Chat/ChatService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ProjectVG.Application/Services/Chat/ChatService.cs b/ProjectVG.Application/Services/Chat/ChatService.cs index 9947bc4..bbf34b5 100644 --- a/ProjectVG.Application/Services/Chat/ChatService.cs +++ b/ProjectVG.Application/Services/Chat/ChatService.cs @@ -59,7 +59,6 @@ public async Task EnqueueChatRequestAsync(ProcessChatComman var preprocessContext = await PrepareChatRequestAsync(command); _ = Task.Run(async () => { - using var processScope = _scopeFactory.CreateScope(); await ProcessChatRequestInternalAsync(preprocessContext); }); @@ -100,8 +99,11 @@ private async Task ProcessChatRequestInternalAsync(ChatPreprocessContext context // 작업 처리 단계: LLM -> TTS -> 결과 전송 + 저장 var llmResult = await _llmProcessor.ProcessAsync(context); await _ttsProcessor.ProcessAsync(context, llmResult); - await _resultProcessor.SendResultsAsync(context, llmResult); - await _resultProcessor.PersistResultsAsync(context, llmResult); + + using var scope = _scopeFactory.CreateScope(); + var resultProcessor = scope.ServiceProvider.GetRequiredService(); + await resultProcessor.SendResultsAsync(context, llmResult); + await resultProcessor.PersistResultsAsync(context, llmResult); _logger.LogInformation("채팅 요청 처리 완료: {SessionId}, 토큰: {TokensUsed}", context.SessionId, llmResult.TokensUsed); From 197e9ced20835197ddd65291f2927b5802528b82 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sat, 16 Aug 2025 19:14:39 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20AOP=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=B9=84=EC=9A=A9=20=EC=82=B0=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 데코레이터 패턴 (리플렉션) 방법을 사용한 비용 산출 방법 각 주요 프로세스마다 시간, 비용을 측정하여 기록하고 저장 --- .../ApplicationServiceCollectionExtensions.cs | 7 ++ .../Models/Chat/ChatMetrics.cs | 25 +++++ .../Services/Chat/ChatService.cs | 19 +++- .../Chat/CostTracking/ChatMetricsService.cs | 92 ++++++++++++++++++ .../CostTracking/CostTrackingDecorator.cs | 96 +++++++++++++++++++ .../CostTrackingDecoratorFactory.cs | 32 +++++++ .../Chat/CostTracking/IChatMetricsService.cs | 14 +++ .../CostTracking/ICostTrackingDecorator.cs | 11 +++ 8 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 ProjectVG.Application/Models/Chat/ChatMetrics.cs create mode 100644 ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs create mode 100644 ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs create mode 100644 ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecoratorFactory.cs create mode 100644 ProjectVG.Application/Services/Chat/CostTracking/IChatMetricsService.cs create mode 100644 ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index 5859708..381de78 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ 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; @@ -32,6 +33,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddSingleton(); + + services.AddScoped(); + + // 비용 추적 데코레이터 등록 + services.AddCostTrackingDecorator("LLM_Processing"); + services.AddCostTrackingDecorator("TTS_Processing"); return services; } 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/Services/Chat/ChatService.cs b/ProjectVG.Application/Services/Chat/ChatService.cs index bbf34b5..5fff94d 100644 --- a/ProjectVG.Application/Services/Chat/ChatService.cs +++ b/ProjectVG.Application/Services/Chat/ChatService.cs @@ -1,6 +1,7 @@ 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.Conversation; using ProjectVG.Application.Services.Character; using Microsoft.Extensions.DependencyInjection; @@ -21,9 +22,10 @@ public class ChatService : IChatService private readonly UserInputAnalysisProcessor _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; public ChatService( IServiceScopeFactory scopeFactory, @@ -34,9 +36,10 @@ public ChatService( MemoryContextPreprocessor memoryPreprocessor, UserInputAnalysisProcessor inputProcessor, UserInputActionProcessor actionProcessor, - ChatLLMProcessor llmProcessor, - ChatTTSProcessor ttsProcessor, - ChatResultProcessor resultProcessor + ICostTrackingDecorator llmProcessor, + ICostTrackingDecorator ttsProcessor, + ChatResultProcessor resultProcessor, + IChatMetricsService metricsService ) { _scopeFactory = scopeFactory; _logger = logger; @@ -50,10 +53,13 @@ ChatResultProcessor resultProcessor _llmProcessor = llmProcessor; _ttsProcessor = ttsProcessor; _resultProcessor = resultProcessor; + _metricsService = metricsService; } 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); @@ -105,6 +111,9 @@ private async Task ProcessChatRequestInternalAsync(ChatPreprocessContext context await resultProcessor.SendResultsAsync(context, llmResult); await resultProcessor.PersistResultsAsync(context, llmResult); + _metricsService.EndChatMetrics(); + _metricsService.LogChatMetrics(); + _logger.LogInformation("채팅 요청 처리 완료: {SessionId}, 토큰: {TokensUsed}", context.SessionId, llmResult.TokensUsed); } diff --git a/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs b/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs new file mode 100644 index 0000000..1a5d7cc --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs @@ -0,0 +1,92 @@ +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}"); + + _logger.LogInformation( + "채팅 메트릭 - SessionId: {SessionId}, 총 비용: {TotalCost:C}, 총 시간: {TotalDuration}", + metrics.SessionId, metrics.TotalCost, metrics.TotalDuration); + + foreach (var process in metrics.ProcessMetrics) + { + _logger.LogInformation( + " - {ProcessName}: {Duration}ms, 비용: {Cost:C}", + process.ProcessName, process.Duration.TotalMilliseconds, process.Cost); + } + } + } +} diff --git a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs new file mode 100644 index 0000000..86ec5c6 --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs @@ -0,0 +1,96 @@ +using ProjectVG.Application.Models.Chat; +using ProjectVG.Application.Services.Chat.CostTracking; + +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(ChatPreprocessContext context) + { + _metricsService.StartProcessMetrics(_processName); + + try + { + // 리플렉션으로 ProcessAsync 메서드 호출 + var method = typeof(T).GetMethod("ProcessAsync", new[] { typeof(ChatPreprocessContext) }); + + if (method == null) + throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); + + var result = await (Task)method.Invoke(_service, new object[] { context })!; + + // Cost 값만 직접 추출 + var cost = ExtractCost(result); + Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: {cost:C}"); + _metricsService.EndProcessMetrics(_processName, cost); + return result; + } + catch (Exception ex) + { + _metricsService.EndProcessMetrics(_processName, 0, ex.Message); + throw; + } + } + + public async Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult result) + { + _metricsService.StartProcessMetrics(_processName); + + try + { + // 리플렉션으로 ProcessAsync 메서드 호출 (void 반환) + var method = typeof(T).GetMethod("ProcessAsync", + new[] { typeof(ChatPreprocessContext), typeof(ChatProcessResult) }); + + if (method == null) + throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); + + await (Task)method.Invoke(_service, new object[] { context, result })!; + + // Cost 값만 직접 추출 + var cost = ExtractCost(result); + Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: {cost:C}"); + _metricsService.EndProcessMetrics(_processName, cost); + } + 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..4359da0 --- /dev/null +++ b/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs @@ -0,0 +1,11 @@ +using ProjectVG.Application.Models.Chat; + +namespace ProjectVG.Application.Services.Chat.CostTracking +{ + public interface ICostTrackingDecorator where T : class + { + T Service { get; } + Task ProcessAsync(ChatPreprocessContext context); + Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult result); + } +} From 95b63cc049c6f07f506ee8bcec2d3f15503c4509 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sat, 16 Aug 2025 20:03:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20LLM=EB=B0=8F=20TTS=20=EC=BD=94?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CostTracking/CostTrackingDecorator.cs | 26 +++++++++++++--- .../Services/Chat/Factories/ChatLLMFormat.cs | 8 ++--- .../Services/Chat/Factories/ILLMFormat.cs | 2 +- .../Factories/UserInputAnalysisLLMFormat.cs | 9 ++---- .../Chat/Processors/ChatLLMProcessor.cs | 7 +++-- .../Chat/Processors/ChatTTSProcessor.cs | 4 ++- ProjectVG.Common/Constants/LLMModelInfo.cs | 30 +++++++++++++++++-- ProjectVG.Common/Constants/TTSCostInfo.cs | 21 +++++++++++++ .../Integrations/LLMClient/LLMClient.cs | 3 ++ .../LLMClient/Models/LLMResponse.cs | 20 ++++++++++++- 10 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 ProjectVG.Common/Constants/TTSCostInfo.cs diff --git a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs index 86ec5c6..26348c8 100644 --- a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs +++ b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs @@ -51,11 +51,20 @@ public async Task ProcessAsync(ChatPreprocessContext context) if (method == null) throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); - var result = await (Task)method.Invoke(_service, new object[] { context })!; + 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}"); + + var result = await taskResult!; // Cost 값만 직접 추출 var cost = ExtractCost(result); - Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: {cost:C}"); + var costInDollars = (double)cost / 100_000.0; + Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: ${costInDollars:F6}"); + Console.WriteLine($"[COST_TRACKING] {_processName} - 원본 결과 타입: {result?.GetType().Name}, Cost 속성 값: {result?.GetType().GetProperty("Cost")?.GetValue(result)}"); _metricsService.EndProcessMetrics(_processName, cost); return result; } @@ -79,11 +88,20 @@ public async Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult if (method == null) throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); - await (Task)method.Invoke(_service, new object[] { context, result })!; + var invokeResult = method.Invoke(_service, new object[] { context, result }); + 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(result); - Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: {cost:C}"); + var costInDollars = (double)cost / 100_000.0; + Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: ${costInDollars:F6}"); + Console.WriteLine($"[COST_TRACKING] {_processName} - 원본 결과 타입: {result?.GetType().Name}, Cost 속성 값: {result?.GetType().GetProperty("Cost")?.GetValue(result)}"); _metricsService.EndProcessMetrics(_processName, cost); } catch (Exception ex) diff --git a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs index 207b267..dbab37e 100644 --- a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs @@ -67,13 +67,9 @@ public ChatOutputFormatResult Parse(string llmResponse, ChatPreprocessContext in return ParseChatResponse(llmResponse, input.VoiceName); } - 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() 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/Processors/ChatLLMProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs index a66c832..40bfc6e 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs @@ -34,11 +34,12 @@ public async Task ProcessAsync(ChatPreprocessContext context) // 결과 파싱 var parsed = format.Parse(llmResponse.Response, context); - var cost = format.CalculateCost(llmResponse.TokensUsed); + var cost = format.CalculateCost(llmResponse.PromptTokens, llmResponse.CompletionTokens); var segments = CreateSegments(parsed); - _logger.LogDebug("LLM 처리 완료: 세션 {SessionId}, 토큰 {TokensUsed}, 비용 {Cost}", - context.SessionId, llmResponse.TokensUsed, cost); + Console.WriteLine($"[LLM_DEBUG] ID: {llmResponse.Id}, 입력 토큰: {llmResponse.PromptTokens}, 출력 토큰: {llmResponse.CompletionTokens}, 총 토큰: {llmResponse.TokensUsed}, 계산된 비용: {cost:F0} Cost"); + _logger.LogDebug("LLM 처리 완료: 세션 {SessionId}, ID {Id}, 입력 토큰 {PromptTokens}, 출력 토큰 {CompletionTokens}, 총 토큰 {TotalTokens}, 비용 {Cost}", + context.SessionId, llmResponse.Id, llmResponse.PromptTokens, llmResponse.CompletionTokens, llmResponse.TokensUsed, cost); return new ChatProcessResult { Response = parsed.Response, diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs index 3ccf9de..3e3329b 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs @@ -115,7 +115,9 @@ private void ApplyTTSResultToSegment(ChatMessageSegment segment, TextToSpeechRes private void UpdateCost(ChatProcessResult result, TextToSpeechResponse ttsResult) { if (ttsResult.AudioLength.HasValue) { - result.Cost += Math.Ceiling(ttsResult.AudioLength.Value / 0.1); + var ttsCost = TTSCostInfo.CalculateTTSCost(ttsResult.AudioLength.Value); + result.Cost += ttsCost; + Console.WriteLine($"[TTS_DEBUG] 오디오 길이: {ttsResult.AudioLength.Value:F2}초, TTS 비용: {ttsCost:F0} Cost"); } } 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..3ee9910 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, + PromptTokens = 30, + CompletionTokens = 20, ResponseTime = 100 }; } diff --git a/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs b/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs index c2aa3ee..5f972d5 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; } + /// + /// 입력 토큰 수 (prompt_tokens) + /// + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; set; } + + /// + /// 출력 토큰 수 (completion_tokens) + /// + [JsonPropertyName("completion_tokens")] + public int CompletionTokens { get; set; } + /// /// 처리 시간 (ms) /// From 6d9fc889099d9b19430d29f829595c23625bc7ab Mon Sep 17 00:00:00 2001 From: WooSH Date: Sat, 16 Aug 2025 20:07:31 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9D=98=EB=8F=84=20=ED=8C=8C=EC=95=85=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=98=20=EB=B9=84=EC=9A=A9=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApplicationServiceCollectionExtensions.cs | 1 + ProjectVG.Application/Models/Chat/UserInputAnalysis.cs | 7 +++++-- .../Chat/Preprocessors/UserInputAnalysisProcessor.cs | 7 +++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index 381de78..ab92218 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // 비용 추적 데코레이터 등록 services.AddCostTrackingDecorator("LLM_Processing"); services.AddCostTrackingDecorator("TTS_Processing"); + services.AddCostTrackingDecorator("User_Input_Analysis"); return services; } 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/Preprocessors/UserInputAnalysisProcessor.cs b/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs index 7266d33..17bdfe5 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.PromptTokens, llmResponse.CompletionTokens); 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.PromptTokens}, 출력 토큰: {llmResponse.CompletionTokens}, 총 토큰: {llmResponse.TokensUsed}, 계산된 비용: {cost:F0} Cost"); + _logger.LogDebug("사용자 입력 분석 완료: '{Input}' -> 맥락: {Context}, 의도: {Intent}, 액션: {Action}, 비용: {Cost}", + userInput, analysis.ConversationContext, analysis.UserIntent, analysis.Action, cost); return analysis; } From b08e756b0d0186e2d27f75c69a0d27dc4cfdc160 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sat, 16 Aug 2025 20:24:11 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20input/output=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=20=EB=B6=84=EC=84=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=98=90=ED=95=9C=20=EB=B9=84=EC=9A=A9=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Chat/ChatService.cs | 4 +- .../Chat/CostTracking/ChatMetricsService.cs | 10 +++-- .../CostTracking/CostTrackingDecorator.cs | 42 +++++++++++++++++-- .../CostTracking/ICostTrackingDecorator.cs | 2 + .../UserInputAnalysisProcessor.cs | 4 +- .../Chat/Processors/ChatLLMProcessor.cs | 8 ++-- .../Integrations/LLMClient/LLMClient.cs | 4 +- .../LLMClient/Models/LLMResponse.cs | 12 +++--- 8 files changed, 62 insertions(+), 24 deletions(-) diff --git a/ProjectVG.Application/Services/Chat/ChatService.cs b/ProjectVG.Application/Services/Chat/ChatService.cs index 5fff94d..eb51f3f 100644 --- a/ProjectVG.Application/Services/Chat/ChatService.cs +++ b/ProjectVG.Application/Services/Chat/ChatService.cs @@ -19,7 +19,7 @@ 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 ICostTrackingDecorator _llmProcessor; @@ -34,7 +34,7 @@ public ChatService( ICharacterService characterService, ChatRequestValidator validator, MemoryContextPreprocessor memoryPreprocessor, - UserInputAnalysisProcessor inputProcessor, + ICostTrackingDecorator inputProcessor, UserInputActionProcessor actionProcessor, ICostTrackingDecorator llmProcessor, ICostTrackingDecorator ttsProcessor, diff --git a/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs b/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs index 1a5d7cc..0dcc4f5 100644 --- a/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs +++ b/ProjectVG.Application/Services/Chat/CostTracking/ChatMetricsService.cs @@ -77,15 +77,17 @@ public void LogChatMetrics() Console.WriteLine($"[METRICS] 채팅 메트릭 로그 시작: {metrics.SessionId}"); + var totalCostInDollars = (double)metrics.TotalCost / 100_000.0; _logger.LogInformation( - "채팅 메트릭 - SessionId: {SessionId}, 총 비용: {TotalCost:C}, 총 시간: {TotalDuration}", - metrics.SessionId, metrics.TotalCost, metrics.TotalDuration); + "채팅 메트릭 - 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:C}", - process.ProcessName, process.Duration.TotalMilliseconds, process.Cost); + " - {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 index 26348c8..a315270 100644 --- a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs +++ b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs @@ -1,5 +1,6 @@ using ProjectVG.Application.Models.Chat; using ProjectVG.Application.Services.Chat.CostTracking; +using ProjectVG.Domain.Entities.ConversationHistorys; namespace ProjectVG.Application.Services.Chat.CostTracking { @@ -62,8 +63,7 @@ public async Task ProcessAsync(ChatPreprocessContext context) // Cost 값만 직접 추출 var cost = ExtractCost(result); - var costInDollars = (double)cost / 100_000.0; - Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: ${costInDollars:F6}"); + 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; @@ -99,8 +99,7 @@ public async Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult // Cost 값만 직접 추출 var cost = ExtractCost(result); - var costInDollars = (double)cost / 100_000.0; - Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: ${costInDollars:F6}"); + 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); } @@ -110,5 +109,40 @@ public async Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult 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/ICostTrackingDecorator.cs b/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs index 4359da0..fa78c50 100644 --- a/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs +++ b/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs @@ -1,4 +1,5 @@ using ProjectVG.Application.Models.Chat; +using ProjectVG.Domain.Entities.ConversationHistorys; namespace ProjectVG.Application.Services.Chat.CostTracking { @@ -7,5 +8,6 @@ public interface ICostTrackingDecorator where T : class T Service { get; } Task ProcessAsync(ChatPreprocessContext context); Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult result); + Task ProcessAsync(string userInput, IEnumerable conversationHistory); } } diff --git a/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs b/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs index 17bdfe5..adb69d6 100644 --- a/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Preprocessors/UserInputAnalysisProcessor.cs @@ -41,11 +41,11 @@ public async Task ProcessAsync(string userInput, IEnumerable< temperature: format.Temperature ); - var cost = format.CalculateCost(llmResponse.PromptTokens, llmResponse.CompletionTokens); + var cost = format.CalculateCost(llmResponse.InputTokens, llmResponse.OutputTokens); var analysis = format.Parse(llmResponse.Response, userInput); analysis.Cost = cost; - Console.WriteLine($"[USER_INPUT_ANALYSIS_DEBUG] ID: {llmResponse.Id}, 입력 토큰: {llmResponse.PromptTokens}, 출력 토큰: {llmResponse.CompletionTokens}, 총 토큰: {llmResponse.TokensUsed}, 계산된 비용: {cost:F0} Cost"); + 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); diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs index 40bfc6e..6f58fe1 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs @@ -34,12 +34,12 @@ public async Task ProcessAsync(ChatPreprocessContext context) // 결과 파싱 var parsed = format.Parse(llmResponse.Response, context); - var cost = format.CalculateCost(llmResponse.PromptTokens, llmResponse.CompletionTokens); + var cost = format.CalculateCost(llmResponse.InputTokens, llmResponse.OutputTokens); var segments = CreateSegments(parsed); - Console.WriteLine($"[LLM_DEBUG] ID: {llmResponse.Id}, 입력 토큰: {llmResponse.PromptTokens}, 출력 토큰: {llmResponse.CompletionTokens}, 총 토큰: {llmResponse.TokensUsed}, 계산된 비용: {cost:F0} Cost"); - _logger.LogDebug("LLM 처리 완료: 세션 {SessionId}, ID {Id}, 입력 토큰 {PromptTokens}, 출력 토큰 {CompletionTokens}, 총 토큰 {TotalTokens}, 비용 {Cost}", - context.SessionId, llmResponse.Id, llmResponse.PromptTokens, llmResponse.CompletionTokens, llmResponse.TokensUsed, cost); + Console.WriteLine($"[LLM_DEBUG] ID: {llmResponse.Id}, 입력 토큰: {llmResponse.InputTokens}, 출력 토큰: {llmResponse.OutputTokens}, 총 토큰: {llmResponse.TokensUsed}, 계산된 비용: {cost:F0} Cost"); + _logger.LogDebug("LLM 처리 완료: 세션 {SessionId}, ID {Id}, 입력 토큰 {InputTokens}, 출력 토큰 {OutputTokens}, 총 토큰 {TotalTokens}, 비용 {Cost}", + context.SessionId, llmResponse.Id, llmResponse.InputTokens, llmResponse.OutputTokens, llmResponse.TokensUsed, cost); return new ChatProcessResult { Response = parsed.Response, diff --git a/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs b/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs index 3ee9910..e71e281 100644 --- a/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs +++ b/ProjectVG.Infrastructure/Integrations/LLMClient/LLMClient.cs @@ -82,8 +82,8 @@ public async Task SendRequestAsync(LLMRequest request) Id = "mock-chatcmpl-" + Guid.NewGuid().ToString("N")[..8], Response = "안녕하세요! 저는 현재 Mock 모드로 동작하고 있습니다. 실제 LLM 서비스가 연결되지 않았습니다.", TokensUsed = 50, - PromptTokens = 30, - CompletionTokens = 20, + 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 5f972d5..9740499 100644 --- a/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs +++ b/ProjectVG.Infrastructure/Integrations/LLMClient/Models/LLMResponse.cs @@ -29,16 +29,16 @@ public class LLMResponse public int TokensUsed { get; set; } /// - /// 입력 토큰 수 (prompt_tokens) + /// 입력 토큰 수 (input_tokens) /// - [JsonPropertyName("prompt_tokens")] - public int PromptTokens { get; set; } + [JsonPropertyName("input_tokens")] + public int InputTokens { get; set; } /// - /// 출력 토큰 수 (completion_tokens) + /// 출력 토큰 수 (output_tokens) /// - [JsonPropertyName("completion_tokens")] - public int CompletionTokens { get; set; } + [JsonPropertyName("output_tokens")] + public int OutputTokens { get; set; } /// /// 처리 시간 (ms) From a050c89e8e9f774bb196e3a75f210d59a19a16fb Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 17 Aug 2025 22:34:15 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EC=8B=A4=ED=8C=A8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApplicationServiceCollectionExtensions.cs | 2 + .../Services/Chat/ChatService.cs | 20 ++++---- .../Chat/Handlers/ChatFailureHandler.cs | 48 +++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index ab92218..834271d 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ 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; @@ -28,6 +29,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/ProjectVG.Application/Services/Chat/ChatService.cs b/ProjectVG.Application/Services/Chat/ChatService.cs index eb51f3f..4e39c4e 100644 --- a/ProjectVG.Application/Services/Chat/ChatService.cs +++ b/ProjectVG.Application/Services/Chat/ChatService.cs @@ -2,6 +2,7 @@ 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; @@ -26,6 +27,7 @@ public class ChatService : IChatService private readonly ICostTrackingDecorator _ttsProcessor; private readonly ChatResultProcessor _resultProcessor; private readonly IChatMetricsService _metricsService; + private readonly ChatFailureHandler _failureHandler; public ChatService( IServiceScopeFactory scopeFactory, @@ -39,7 +41,8 @@ public ChatService( ICostTrackingDecorator llmProcessor, ICostTrackingDecorator ttsProcessor, ChatResultProcessor resultProcessor, - IChatMetricsService metricsService + IChatMetricsService metricsService, + ChatFailureHandler failureHandler ) { _scopeFactory = scopeFactory; _logger = logger; @@ -54,6 +57,7 @@ IChatMetricsService metricsService _ttsProcessor = ttsProcessor; _resultProcessor = resultProcessor; _metricsService = metricsService; + _failureHandler = failureHandler; } public async Task EnqueueChatRequestAsync(ProcessChatCommand command) @@ -100,8 +104,6 @@ private async Task PrepareChatRequestAsync(ProcessChatCom private async Task ProcessChatRequestInternalAsync(ChatPreprocessContext context) { try { - _logger.LogInformation("채팅 요청 처리 시작: {SessionId}", context.SessionId); - // 작업 처리 단계: LLM -> TTS -> 결과 전송 + 저장 var llmResult = await _llmProcessor.ProcessAsync(context); await _ttsProcessor.ProcessAsync(context, llmResult); @@ -110,15 +112,13 @@ private async Task ProcessChatRequestInternalAsync(ChatPreprocessContext context var resultProcessor = scope.ServiceProvider.GetRequiredService(); await resultProcessor.SendResultsAsync(context, llmResult); await resultProcessor.PersistResultsAsync(context, llmResult); - - _metricsService.EndChatMetrics(); - _metricsService.LogChatMetrics(); - - _logger.LogInformation("채팅 요청 처리 완료: {SessionId}, 토큰: {TokensUsed}", - context.SessionId, llmResult.TokensUsed); } 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/Handlers/ChatFailureHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs new file mode 100644 index 0000000..2613493 --- /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(ChatPreprocessContext context, Exception exception) + { + _logger.LogError(exception, "채팅 처리 실패: 세션 {SessionId}", context.SessionId); + return SendErrorMessageAsync(context, "요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + + private async Task SendErrorMessageAsync(ChatPreprocessContext 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); + } + } + } +} From 0948bc75ef77f83a76ef10af61feeefb3515c74f Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 17 Aug 2025 22:34:52 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20LLM=20=EA=B2=B0=EA=B3=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=8B=A8=EA=B3=84=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Models/Chat/ChatMessageSegment.cs | 69 +-------------- .../Models/Chat/ChatOutputFormatResult.cs | 9 -- .../Models/Chat/ChatProcessResult.cs | 1 - .../Services/Chat/Factories/ChatLLMFormat.cs | 84 ++++++++++--------- .../Chat/Processors/ChatLLMProcessor.cs | 26 ++---- 5 files changed, 52 insertions(+), 137 deletions(-) delete mode 100644 ProjectVG.Application/Models/Chat/ChatOutputFormatResult.cs diff --git a/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs b/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs index 410240d..b52b51a 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) { @@ -68,28 +23,6 @@ public static ChatMessageSegment CreateTextOnly(string text, int order = 0) }; } - public static ChatMessageSegment CreateAudioOnly(byte[] audioData, string contentType, float? audioLength, int order = 0) - { - return new ChatMessageSegment - { - AudioData = audioData, - AudioContentType = contentType, - AudioLength = audioLength, - Order = order - }; - } - - 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/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/ChatProcessResult.cs b/ProjectVG.Application/Models/Chat/ChatProcessResult.cs index 3089f51..3868824 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessResult.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessResult.cs @@ -3,7 +3,6 @@ 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(); diff --git a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs index dbab37e..8fc3b48 100644 --- a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs @@ -5,7 +5,7 @@ namespace ProjectVG.Application.Services.Chat.Factories { - public class ChatLLMFormat : ILLMFormat + public class ChatLLMFormat : ILLMFormat> { public ChatLLMFormat() { @@ -29,7 +29,6 @@ public string GetInstructions(ChatPreprocessContext 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,9 +59,9 @@ 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, ChatPreprocessContext input) { - return ParseChatResponse(llmResponse, input.VoiceName); + return ParseChatResponseToSegments(llmResponse, input.VoiceName); } public double CalculateCost(int promptTokens, int completionTokens) @@ -85,54 +82,63 @@ [neutral] 내가 그런다고 좋아할 것 같아? [shy] 하지만 츄 해준 "; } - private ChatOutputFormatResult ParseChatResponse(string llmText, string? voiceName = null) + private List ParseChatResponseToSegments(string llmText, string? voiceName = 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(voiceName); 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? voiceName) + { + if (string.IsNullOrWhiteSpace(voiceName)) + return null; + + var profile = VoiceCatalog.GetProfile(voiceName); + 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/Processors/ChatLLMProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs index 6f58fe1..3a86a1a 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs @@ -32,33 +32,19 @@ public async Task ProcessAsync(ChatPreprocessContext context) temperature: format.Temperature ); - // 결과 파싱 - var parsed = format.Parse(llmResponse.Response, context); + var segments = format.Parse(llmResponse.Response, context); + var cost = format.CalculateCost(llmResponse.InputTokens, llmResponse.OutputTokens); - var segments = CreateSegments(parsed); - Console.WriteLine($"[LLM_DEBUG] ID: {llmResponse.Id}, 입력 토큰: {llmResponse.InputTokens}, 출력 토큰: {llmResponse.OutputTokens}, 총 토큰: {llmResponse.TokensUsed}, 계산된 비용: {cost:F0} Cost"); - _logger.LogDebug("LLM 처리 완료: 세션 {SessionId}, ID {Id}, 입력 토큰 {InputTokens}, 출력 토큰 {OutputTokens}, 총 토큰 {TotalTokens}, 비용 {Cost}", - context.SessionId, llmResponse.Id, llmResponse.InputTokens, llmResponse.OutputTokens, 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, + Response = llmResponse.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; - } } } From d07f011ce86544c03b5c560777a1af9022387e99 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 17 Aug 2025 23:27:15 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=EB=AA=A8=EB=93=A0=20DTO=EB=A5=BC?= =?UTF-8?q?=20ChatProcessContext=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set을 통한 접근제어 --- .../Models/Chat/ChatMessageSegment.cs | 7 + .../Models/Chat/ChatPreprocessContext.cs | 86 --------- .../Models/Chat/ChatPreprocessResult.cs | 27 --- .../Models/Chat/ChatProcessContext.cs | 74 +++++++ .../Models/Chat/ChatProcessResult.cs | 16 -- .../Models/Chat/ProcessChatCommand.cs | 1 + .../Services/Chat/ChatService.cs | 20 +- .../CostTracking/CostTrackingDecorator.cs | 46 +---- .../CostTracking/ICostTrackingDecorator.cs | 3 +- .../Services/Chat/Factories/ChatLLMFormat.cs | 20 +- .../Chat/Handlers/ChatFailureHandler.cs | 4 +- .../Chat/Processors/ChatLLMProcessor.cs | 13 +- .../Chat/Processors/ChatResultProcessor.cs | 12 +- .../Chat/Processors/ChatTTSProcessor.cs | 180 +++++------------- 14 files changed, 170 insertions(+), 339 deletions(-) delete mode 100644 ProjectVG.Application/Models/Chat/ChatPreprocessContext.cs delete mode 100644 ProjectVG.Application/Models/Chat/ChatPreprocessResult.cs create mode 100644 ProjectVG.Application/Models/Chat/ChatProcessContext.cs delete mode 100644 ProjectVG.Application/Models/Chat/ChatProcessResult.cs diff --git a/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs b/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs index b52b51a..d520e64 100644 --- a/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs +++ b/ProjectVG.Application/Models/Chat/ChatMessageSegment.cs @@ -22,6 +22,13 @@ public static ChatMessageSegment CreateTextOnly(string text, int order = 0) Order = order }; } + + public void SetAudioData(byte[]? audioData, string? audioContentType, float? audioLength) + { + AudioData = audioData; + AudioContentType = audioContentType; + AudioLength = audioLength; + } } 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..1e1c6ce --- /dev/null +++ b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs @@ -0,0 +1,74 @@ +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 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(); + } + + 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(); + + 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 3868824..0000000 --- a/ProjectVG.Application/Models/Chat/ChatProcessResult.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace ProjectVG.Application.Models.Chat -{ - public class ChatProcessResult - { - public string Response { get; set; } = string.Empty; - 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..6d5cbfb 100644 --- a/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs +++ b/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs @@ -19,6 +19,7 @@ public string RequestId public string? Action { get; set; } public string? Instruction { get; set; } + public CharacterDto? Character { get; private set; } internal void SetCharacter(CharacterDto character) diff --git a/ProjectVG.Application/Services/Chat/ChatService.cs b/ProjectVG.Application/Services/Chat/ChatService.cs index 4e39c4e..a9def06 100644 --- a/ProjectVG.Application/Services/Chat/ChatService.cs +++ b/ProjectVG.Application/Services/Chat/ChatService.cs @@ -79,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); @@ -91,27 +89,23 @@ 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 { // 작업 처리 단계: LLM -> TTS -> 결과 전송 + 저장 - var llmResult = await _llmProcessor.ProcessAsync(context); - await _ttsProcessor.ProcessAsync(context, llmResult); + await _llmProcessor.ProcessAsync(context); + await _ttsProcessor.ProcessAsync(context); using var scope = _scopeFactory.CreateScope(); var resultProcessor = scope.ServiceProvider.GetRequiredService(); - await resultProcessor.SendResultsAsync(context, llmResult); - await resultProcessor.PersistResultsAsync(context, llmResult); + await resultProcessor.SendResultsAsync(context); + await resultProcessor.PersistResultsAsync(context); } catch (Exception ex) { await _failureHandler.HandleFailureAsync(context, ex); diff --git a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs index a315270..b2e7779 100644 --- a/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs +++ b/ProjectVG.Application/Services/Chat/CostTracking/CostTrackingDecorator.cs @@ -40,14 +40,14 @@ private decimal ExtractCost(object? result) return 0; } - public async Task ProcessAsync(ChatPreprocessContext context) + public async Task ProcessAsync(ChatProcessContext context) { _metricsService.StartProcessMetrics(_processName); try { // 리플렉션으로 ProcessAsync 메서드 호출 - var method = typeof(T).GetMethod("ProcessAsync", new[] { typeof(ChatPreprocessContext) }); + var method = typeof(T).GetMethod("ProcessAsync", new[] { typeof(ChatProcessContext) }); if (method == null) throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); @@ -56,51 +56,15 @@ public async Task ProcessAsync(ChatPreprocessContext 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}"); - - 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; - } - } - - public async Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult result) - { - _metricsService.StartProcessMetrics(_processName); - - try - { - // 리플렉션으로 ProcessAsync 메서드 호출 (void 반환) - var method = typeof(T).GetMethod("ProcessAsync", - new[] { typeof(ChatPreprocessContext), typeof(ChatProcessResult) }); - - if (method == null) - throw new InvalidOperationException($"ProcessAsync 메서드를 찾을 수 없습니다: {typeof(T).Name}"); - - var invokeResult = method.Invoke(_service, new object[] { context, result }); - 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(result); + var cost = ExtractCost(context); Console.WriteLine($"[COST_TRACKING] {_processName} - 추출된 비용: {cost:F0} Cost"); - Console.WriteLine($"[COST_TRACKING] {_processName} - 원본 결과 타입: {result?.GetType().Name}, Cost 속성 값: {result?.GetType().GetProperty("Cost")?.GetValue(result)}"); + Console.WriteLine($"[COST_TRACKING] {_processName} - 컨텍스트 타입: {context?.GetType().Name}, Cost 속성 값: {context?.GetType().GetProperty("Cost")?.GetValue(context)}"); _metricsService.EndProcessMetrics(_processName, cost); } catch (Exception ex) @@ -110,6 +74,8 @@ public async Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult } } + + public async Task ProcessAsync(string userInput, IEnumerable conversationHistory) { _metricsService.StartProcessMetrics(_processName); diff --git a/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs b/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs index fa78c50..61b1e66 100644 --- a/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs +++ b/ProjectVG.Application/Services/Chat/CostTracking/ICostTrackingDecorator.cs @@ -6,8 +6,7 @@ namespace ProjectVG.Application.Services.Chat.CostTracking public interface ICostTrackingDecorator where T : class { T Service { get; } - Task ProcessAsync(ChatPreprocessContext context); - Task ProcessAsync(ChatPreprocessContext context, ChatProcessResult result); + 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 8fc3b48..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,7 +25,7 @@ public string GetSystemMessage(ChatPreprocessContext input) return sb.ToString(); } - public string GetInstructions(ChatPreprocessContext input) + public string GetInstructions(ChatProcessContext input) { var sb = new StringBuilder(); @@ -59,9 +59,9 @@ public string GetInstructions(ChatPreprocessContext input) public float Temperature => 0.7f; public int MaxTokens => 1000; - public List Parse(string llmResponse, ChatPreprocessContext input) + public List Parse(string llmResponse, ChatProcessContext input) { - return ParseChatResponseToSegments(llmResponse, input.VoiceName); + return ParseChatResponseToSegments(llmResponse, input.Character?.VoiceId); } public double CalculateCost(int promptTokens, int completionTokens) @@ -82,7 +82,7 @@ [neutral] 내가 그런다고 좋아할 것 같아? [shy] 하지만 츄 해준 "; } - private List ParseChatResponseToSegments(string llmText, string? voiceName = null) + private List ParseChatResponseToSegments(string llmText, string? voiceId = null) { if (string.IsNullOrWhiteSpace(llmText)) return new List(); @@ -92,7 +92,7 @@ private List ParseChatResponseToSegments(string llmText, str var seenTexts = new HashSet(StringComparer.OrdinalIgnoreCase); var matches = Regex.Matches(response, @"\[(.*?)\]\s*([^\[]+)"); - var emotionMap = GetEmotionMap(voiceName); + var emotionMap = GetEmotionMap(voiceId); if (matches.Count > 0) { @@ -108,12 +108,12 @@ private List ParseChatResponseToSegments(string llmText, str return segments; } - private Dictionary? GetEmotionMap(string? voiceName) + private Dictionary? GetEmotionMap(string? voiceId) { - if (string.IsNullOrWhiteSpace(voiceName)) + if (string.IsNullOrWhiteSpace(voiceId)) return null; - var profile = VoiceCatalog.GetProfile(voiceName); + var profile = VoiceCatalog.GetProfileById(voiceId); return profile?.EmotionMap; } diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs index 2613493..d02d181 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs @@ -26,13 +26,13 @@ public ChatFailureHandler( _memoryClient = memoryClient; } - public Task HandleFailureAsync(ChatPreprocessContext context, Exception exception) + public Task HandleFailureAsync(ChatProcessContext context, Exception exception) { _logger.LogError(exception, "채팅 처리 실패: 세션 {SessionId}", context.SessionId); return SendErrorMessageAsync(context, "요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); } - private async Task SendErrorMessageAsync(ChatPreprocessContext context, string errorMessage) + private async Task SendErrorMessageAsync(ChatProcessContext context, string errorMessage) { try { diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatLLMProcessor.cs index 3a86a1a..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,26 +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 segments = format.Parse(llmResponse.Response, context); - var cost = format.CalculateCost(llmResponse.InputTokens, llmResponse.OutputTokens); 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 = llmResponse.Response, - Segments = segments, - Cost = cost - }; + 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 3e3329b..c531371 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs @@ -17,174 +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 (string.IsNullOrWhiteSpace(context.Character?.VoiceId) || context.Segments?.Count == 0) { + _logger.LogDebug("TTS 처리 건너뜀: 세션 {SessionId}, 음성ID {VoiceId}, 세그먼트 수 {SegmentCount}", + context.SessionId, 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) { - var ttsCost = TTSCostInfo.CalculateTTSCost(ttsResult.AudioLength.Value); - result.Cost += ttsCost; - Console.WriteLine($"[TTS_DEBUG] 오디오 길이: {ttsResult.AudioLength.Value:F2}초, TTS 비용: {ttsCost:F0} Cost"); - } - } - - 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자를 초과할 수 없습니다."); - } } } From d05563685e305bf6732350e1eb2acbd337f83e9a Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 18 Aug 2025 10:21:19 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20tts=20=EC=82=AC=EC=9A=A9=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProjectVG.Api/Models/Chat/Request/ChatRequest.cs | 6 +++++- ProjectVG.Application/Models/Chat/ChatProcessContext.cs | 3 +++ ProjectVG.Application/Models/Chat/ProcessChatCommand.cs | 2 +- .../Services/Chat/Processors/ChatTTSProcessor.cs | 6 +++--- 4 files changed, 12 insertions(+), 5 deletions(-) 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/Models/Chat/ChatProcessContext.cs b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs index 1e1c6ce..d0900ec 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs @@ -10,6 +10,7 @@ public class ChatProcessContext 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; } @@ -31,6 +32,7 @@ public ChatProcessContext(ProcessChatCommand command) CharacterId = command.CharacterId; UserMessage = command.Message; MemoryStore = command.UserId.ToString(); + UseTTS = command.UseTTS; } public ChatProcessContext( @@ -44,6 +46,7 @@ public ChatProcessContext( CharacterId = command.CharacterId; UserMessage = command.Message; MemoryStore = command.UserId.ToString(); + UseTTS = command.UseTTS; Character = character; ConversationHistory = conversationHistory; diff --git a/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs b/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs index 6d5cbfb..37c3e75 100644 --- a/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs +++ b/ProjectVG.Application/Models/Chat/ProcessChatCommand.cs @@ -18,7 +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/Services/Chat/Processors/ChatTTSProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs index c531371..b758d20 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs @@ -19,9 +19,9 @@ public ChatTTSProcessor( public async Task ProcessAsync(ChatProcessContext context) { - if (string.IsNullOrWhiteSpace(context.Character?.VoiceId) || context.Segments?.Count == 0) { - _logger.LogDebug("TTS 처리 건너뜀: 세션 {SessionId}, 음성ID {VoiceId}, 세그먼트 수 {SegmentCount}", - context.SessionId, context.Character?.VoiceId, context.Segments?.Count ?? 0); + 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; }