diff --git a/src/InterviewAssistant.ApiService/Delegates/PostChatReportDelegate.cs b/src/InterviewAssistant.ApiService/Delegates/PostChatReportDelegate.cs new file mode 100644 index 0000000..14ce57c --- /dev/null +++ b/src/InterviewAssistant.ApiService/Delegates/PostChatReportDelegate.cs @@ -0,0 +1,16 @@ +using InterviewAssistant.Common.Models; +using InterviewAssistant.ApiService.Services; +using Microsoft.AspNetCore.Mvc; + +namespace InterviewAssistant.ApiService.Delegates; + +public static class ChatReportDelegate +{ + public static async Task PostChatReportAsync( + [FromBody] List messages, + IKernelService kernelService) + { + var report = await kernelService.GenerateReportAsync(messages); + return Results.Ok(report); + } +} \ No newline at end of file diff --git a/src/InterviewAssistant.ApiService/Endpoints/ChatCompletionEndpoint.cs b/src/InterviewAssistant.ApiService/Endpoints/ChatCompletionEndpoint.cs index 2392df4..32958f2 100644 --- a/src/InterviewAssistant.ApiService/Endpoints/ChatCompletionEndpoint.cs +++ b/src/InterviewAssistant.ApiService/Endpoints/ChatCompletionEndpoint.cs @@ -32,6 +32,13 @@ public static IEndpointRouteBuilder MapChatCompletionEndpoint(this IEndpointRout .WithName("PostInterviewData") .WithOpenApi(); + // 면접 리포트 생성 엔드포인트 + api.MapPost("report", ChatReportDelegate.PostChatReportAsync) + .Accepts>(contentType: "application/json") + .Produces(statusCode: StatusCodes.Status200OK, contentType: "application/json") + .WithName("PostChatReport") + .WithOpenApi(); + return routeBuilder; } } diff --git a/src/InterviewAssistant.ApiService/Services/KernelService.cs b/src/InterviewAssistant.ApiService/Services/KernelService.cs index 9a3faa5..2390ac5 100644 --- a/src/InterviewAssistant.ApiService/Services/KernelService.cs +++ b/src/InterviewAssistant.ApiService/Services/KernelService.cs @@ -3,6 +3,10 @@ using InterviewAssistant.ApiService.Repositories; using InterviewAssistant.ApiService.Models; +using InterviewAssistant.Common.Models; + +using System.Text; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; @@ -17,6 +21,8 @@ public interface IKernelService { IAsyncEnumerable InvokeInterviewAgentAsync(string resumeContent, string jobDescriptionContent, IEnumerable? messages = null); IAsyncEnumerable PreprocessAndInvokeAsync(Guid resumeId, Guid jobId, string resumeUrl, string jobDescriptionUrl); + + Task GenerateReportAsync(IEnumerable messages); } public class KernelService(Kernel kernel, IMcpClient mcpClient, IInterviewRepository repository) : IKernelService @@ -153,4 +159,84 @@ private static string NormalizeUri(string uri) return uri; } + + public async Task GenerateReportAsync(IEnumerable messages) + { + // 1. 대화 기록을 AI가 이해하기 쉬운 문자열로 변환 + var historyText = new StringBuilder(); + foreach (var message in messages) + { + historyText.AppendLine($"{message.Role}: {message.Message}"); + } + + // 2. AI에게 리포트 생성을 지시하는 프롬프트 정의 + var prompt = """ + 당신은 전문 면접 분석가입니다. 당신의 임무는 다음 면접 대화를 분석하고 구조화된 보고서를 제공하는 것입니다. + 대화는 'User'(지원자)와 'Assistant'(면접관) 사이에서 이루어졌습니다. + + 전체 대화 내용을 바탕으로 다음을 제공해 주세요: + 1. 간결한 종합 피드백. + 2. 주요 강점 3가지 목록. + 3. 주요 약점 또는 개선이 필요한 부분 3가지 목록. + 4. 'Assistant'(면접관)가 한 모든 질문을 '기술(Technical)', '경험(Experience)', '인성(Personality)' 세 가지 유형 중 하나로 분류하고 각 유형의 개수를 제공해 주세요. + + 중요: 전체 출력은 유효한 단일 JSON 객체여야 합니다. 이 JSON 객체 외부에 어떤 텍스트도 포함하지 마세요. + JSON 구조는 다음과 같아야 합니다: + { + "overallFeedback": "...", + "strengths": ["...", "...", "..."], + "weaknesses": ["...", "...", "..."], + "chartData": { + "labels": ["기술", "경험", "인성"], + "values": [기술_질문_수, 경험_질문_수, 인성_질문_수] + } + } + + --- 면접 기록 --- + {{$history}} + """; + + // 3. AI 호출 및 결과 처리 + string? result = null; // AI의 원본 응답을 catch 블록에서도 사용할 수 있도록 변경 + try + { + var reportFunction = kernel.CreateFunctionFromPrompt(prompt); + var arguments = new KernelArguments { { "history", historyText.ToString() } }; + result = await kernel.InvokeAsync(reportFunction, arguments); + + if (string.IsNullOrWhiteSpace(result)) + { + return new InterviewReportModel { OverallFeedback = "AI로부터 응답을 받지 못했습니다." }; + } + + var jsonResponse = result.Trim(); + if (jsonResponse.StartsWith("```json")) + { + jsonResponse = jsonResponse.Substring(7); + } + if (jsonResponse.EndsWith("```")) + { + jsonResponse = jsonResponse.Substring(0, jsonResponse.Length - 3); + } + + var report = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return report ?? new InterviewReportModel { OverallFeedback = "리포트 분석에 실패했습니다." }; + } + catch (System.Text.Json.JsonException jsonEx) // 1. JSON 파싱 오류만 특정 + { + // 2. 실패 시 AI의 원본 응답과 함께 상세 로그 기록 + Console.WriteLine($"AI response JSON parsing failed: {jsonEx.Message}. Raw response from AI: {result}"); + // 3. 사용자에게 더 구체적인 오류 메시지 반환 + return new InterviewReportModel { OverallFeedback = "AI가 분석 결과를 잘못된 형식으로 반환했습니다. 잠시 후 다시 시도해 주세요." }; + } + catch (Exception ex) // 그 외 모든 오류 + { + Console.WriteLine($"An unexpected error occurred while generating the report: {ex}"); // 전체 예외 정보 로깅 + return new InterviewReportModel { OverallFeedback = "리포트 생성 중 예상치 못한 오류가 발생했습니다." }; + } + } } diff --git a/src/InterviewAssistant.Common/Models/InterviewReportModel.cs b/src/InterviewAssistant.Common/Models/InterviewReportModel.cs new file mode 100644 index 0000000..95ce9bc --- /dev/null +++ b/src/InterviewAssistant.Common/Models/InterviewReportModel.cs @@ -0,0 +1,21 @@ +namespace InterviewAssistant.Common.Models; + +/// +/// 면접 결과 리포트의 데이터 구조 +/// +public class InterviewReportModel +{ + public string OverallFeedback { get; set; } = string.Empty; + public List Strengths { get; set; } = []; + public List Weaknesses { get; set; } = []; + public ChartDataModel ChartData { get; set; } = new(); +} + +/// +/// 차트 데이터 구조 +/// +public class ChartDataModel +{ + public List Labels { get; set; } = []; + public List Values { get; set; } = []; +} \ No newline at end of file diff --git a/src/InterviewAssistant.Web/Clients/ChatApiClient.cs b/src/InterviewAssistant.Web/Clients/ChatApiClient.cs index 1467a1c..b9ea3f2 100644 --- a/src/InterviewAssistant.Web/Clients/ChatApiClient.cs +++ b/src/InterviewAssistant.Web/Clients/ChatApiClient.cs @@ -20,6 +20,8 @@ public interface IChatApiClient /// 이력서 및 채용공고 URL이 포함된 요청 객체 /// API 응답 IAsyncEnumerable SendInterviewDataAsync(InterviewDataRequest request); + + Task GenerateReportAsync(List messages); } /// @@ -55,7 +57,7 @@ public async IAsyncEnumerable SendInterviewDataAsync(InterviewData { if (request == null) throw new ArgumentNullException(nameof(request)); // 명시적 예외 처리 - + _logger.LogInformation("ChatApiClient.cs: 인터뷰 데이터 전송 시작"); var httpResponse = await _http.PostAsJsonAsync("/api/chat/interview-data", request); @@ -72,4 +74,18 @@ public async IAsyncEnumerable SendInterviewDataAsync(InterviewData _logger.LogInformation("ChatApiClient.cs: 인터뷰 데이터 전송 완료"); } + + public async Task GenerateReportAsync(List messages) + { + _logger.LogInformation("API에 리포트 생성 요청"); + var httpResponse = await _http.PostAsJsonAsync("/api/chat/report", messages); + + if (httpResponse.IsSuccessStatusCode) + { + return await httpResponse.Content.ReadFromJsonAsync(); + } + + _logger.LogError("리포트 생성 실패: {StatusCode}", httpResponse.StatusCode); + return null; + } } diff --git a/src/InterviewAssistant.Web/Components/App.razor b/src/InterviewAssistant.Web/Components/App.razor index 95826f8..407659a 100644 --- a/src/InterviewAssistant.Web/Components/App.razor +++ b/src/InterviewAssistant.Web/Components/App.razor @@ -12,6 +12,7 @@ + diff --git a/src/InterviewAssistant.Web/Components/Pages/Home.razor b/src/InterviewAssistant.Web/Components/Pages/Home.razor index bc8e139..b5c61bb 100644 --- a/src/InterviewAssistant.Web/Components/Pages/Home.razor +++ b/src/InterviewAssistant.Web/Components/Pages/Home.razor @@ -8,6 +8,7 @@ @inject IJSRuntime JSRuntime @inject ILogger Logger @rendermode InteractiveServer +@inject NavigationManager Navigation 면접 코치 - InterviewAssistant @@ -41,6 +42,17 @@
+ +
+

AI 면접 코치

+ @if (isLinkShared && messages.Count > 2 && isServerOutputEnded) + { + + } +
+
@if (!isLinkShared) @@ -120,6 +132,7 @@ private Guid currentResumeId; private Guid currentJobDescriptionId; private void CloseModal() => showModal = false; + private bool reportGenerating = false; //Blazor 컴포넌트가 처음 초기화될 때 자동으로 호출 protected override async Task OnInitializedAsync() @@ -331,4 +344,14 @@ } await base.OnAfterRenderAsync(firstRender); } + private async Task GoToReport() + { + reportGenerating = true; + StateHasChanged(); // 로딩 상태 UI 반영 + + await ChatService.GenerateReportAsync(messages); + + reportGenerating = false; + Navigation.NavigateTo("/report"); + } } \ No newline at end of file diff --git a/src/InterviewAssistant.Web/Components/Pages/Report.razor b/src/InterviewAssistant.Web/Components/Pages/Report.razor new file mode 100644 index 0000000..f2a27bf --- /dev/null +++ b/src/InterviewAssistant.Web/Components/Pages/Report.razor @@ -0,0 +1,108 @@ +@page "/report" +@using InterviewAssistant.Web.Services +@using Markdig +@inject IChatService ChatService +@inject IJSRuntime JSRuntime +@rendermode InteractiveServer + +면접 결과 리포트 + +
+
+

면접 결과 리포트

+

AI가 분석한 면접 결과 요약입니다.

+
+ +
+
+
+
+
종합 분석
+
+
+

@reportSummary?.OverallFeedback

+
+
+
+
+
상세 분석
+
+
+
👍 강점
+
    + @foreach(var item in reportSummary?.Strengths ?? new()) + { +
  • @item
  • + } +
+
+
개선점
+
    + @foreach(var item in reportSummary?.Weaknesses ?? new()) + { +
  • @item
  • + } +
+
+
+
+
+
+
+
질문 유형 분석
+
+
+ +
+
+
+
+ +
+

전체 대화 기록

+ @foreach (var message in chatHistory) + { +
+
+ @((MarkupString)Markdown.ToHtml(message.Message).Trim()) +
+
+ } +
+
+ +@code { + [Inject] private NavigationManager Navigation { get; set; } = default!; + private List chatHistory = new(); + private InterviewReportModel? reportSummary; + private bool isInitialized = false; + + protected override async Task OnInitializedAsync() + { + chatHistory = ChatService.GetLastChatHistory(); + reportSummary = ChatService.GetLastReportSummary(); + + if (chatHistory.Count == 0 || reportSummary == null) + { + // 데이터가 없으면 홈으로 보냄 + // NavigationManager.NavigateTo("/"); + } + + await base.OnInitializedAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // JS 호출 코드를 OnAfterRenderAsync로 이동 + await JSRuntime.InvokeVoidAsync("setBodyOverflow", "auto"); + + if (reportSummary?.ChartData != null) + { + await JSRuntime.InvokeVoidAsync("createChart", "typeChart", reportSummary.ChartData); + } + } + await base.OnAfterRenderAsync(firstRender); + } +} diff --git a/src/InterviewAssistant.Web/Services/ChatService.cs b/src/InterviewAssistant.Web/Services/ChatService.cs index 5cad000..9106047 100644 --- a/src/InterviewAssistant.Web/Services/ChatService.cs +++ b/src/InterviewAssistant.Web/Services/ChatService.cs @@ -23,6 +23,10 @@ public interface IChatService /// 이력서 및 채용공고 URL이 포함된 요청 객체 /// API 응답 IAsyncEnumerable SendInterviewDataAsync(InterviewDataRequest request); + + Task GenerateReportAsync(List messages); + List GetLastChatHistory(); + InterviewReportModel? GetLastReportSummary(); } /// @@ -34,6 +38,9 @@ public class ChatService(IChatApiClient client, ILoggerFactory loggerFactory) : private readonly ILogger _logger = (loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory))) .CreateLogger(); + private List _lastChatHistory = []; + private InterviewReportModel? _lastReportSummary; + /// public async IAsyncEnumerable SendMessageAsync(IEnumerable messages, Guid resumeId, Guid jobDescriptionId) { @@ -100,4 +107,23 @@ public async IAsyncEnumerable SendInterviewDataAsync(InterviewData _logger.LogInformation("ChatService.c: 인터뷰 데이터 전송 완료"); } + + public async Task GenerateReportAsync(List messages) + { + _logger.LogInformation("리포트 생성 요청 시작"); + // ChatApiClient에 report 호출 메서드를 추가해야 합니다. (다음 단계에서 추가) + var report = await _client.GenerateReportAsync(messages); + + if (report != null) + { + // 수신된 데이터를 서비스 내에 저장 + _lastChatHistory = new List(messages); + _lastReportSummary = report; + } + + return report; + } + + public List GetLastChatHistory() => _lastChatHistory; + public InterviewReportModel? GetLastReportSummary() => _lastReportSummary; } \ No newline at end of file diff --git a/src/InterviewAssistant.Web/wwwroot/css/chat.css b/src/InterviewAssistant.Web/wwwroot/css/chat.css index f5c968e..c6ebcfc 100644 --- a/src/InterviewAssistant.Web/wwwroot/css/chat.css +++ b/src/InterviewAssistant.Web/wwwroot/css/chat.css @@ -450,3 +450,38 @@ body { .submit-btn:hover { background-color: #3a6fc1; } + +/* 채팅 헤더 스타일 추가 */ +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 10%; + border-bottom: 1px solid var(--border-color); + background-color: var(--chat-bg); +} + +.chat-header h2 { + margin: 0; + font-size: 1.25rem; + color: var(--primary-color); +} + +/* 리포트 페이지 전용 스타일 */ +.report-container .history-section { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; +} + +/* 리포트 페이지의 대화 기록에 스크롤 적용 */ +.report-container .history-section .message { + max-width: 100%; /* 너비 제한 해제 */ +} + +/* 대화 기록을 담을 스크롤 가능한 컨테이너 */ +.history-scroll-container { + max-height: 600px; /* 최대 높이 지정 */ + overflow-y: auto; /* 세로 스크롤 생성 */ + padding-right: 10px; +} \ No newline at end of file diff --git a/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js b/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js index c44d6fe..beccb7c 100644 --- a/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js +++ b/src/InterviewAssistant.Web/wwwroot/js/chatFunctions.js @@ -104,6 +104,42 @@ window.resetTextAreaHeight = function (elementId) { } }; +window.createChart = (canvasId, chartData) => { + const ctx = document.getElementById(canvasId); + if (!ctx) return; + + new Chart(ctx, { + type: "pie", // 'bar'로 바꾸면 막대그래프가 됩니다. + data: { + labels: chartData.labels, + datasets: [ + { + label: "질문 유형 분석", + data: chartData.values, + backgroundColor: [ + "rgba(255, 99, 132, 0.7)", + "rgba(54, 162, 235, 0.7)", + "rgba(255, 206, 86, 0.7)", + "rgba(75, 192, 192, 0.7)", + "rgba(153, 102, 255, 0.7)", + ], + borderColor: "rgba(255, 255, 255, 1)", + borderWidth: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: true, + }, + }); +}; + +// body 태그의 overflow 스타일을 설정하는 함수 +window.setBodyOverflow = (style) => { + document.body.style.overflow = style; +}; + window.isMessageSendInProgress = function () { return window.isSend || false; }; \ No newline at end of file diff --git a/test/InterviewAssistant.ApiService.Tests/Delegates/ChatReportDelegateTests.cs b/test/InterviewAssistant.ApiService.Tests/Delegates/ChatReportDelegateTests.cs new file mode 100644 index 0000000..b4a79d5 --- /dev/null +++ b/test/InterviewAssistant.ApiService.Tests/Delegates/ChatReportDelegateTests.cs @@ -0,0 +1,223 @@ +using InterviewAssistant.ApiService.Delegates; +using InterviewAssistant.ApiService.Services; +using InterviewAssistant.Common.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Shouldly; + +namespace InterviewAssistant.ApiService.Tests.Delegates; + +[TestFixture] +public class ChatReportDelegateTests +{ + private IKernelService _kernelService; + private IServiceProvider _serviceProvider; + + [SetUp] + public void Setup() + { + _kernelService = Substitute.For(); + + // 완전한 서비스 프로바이더 설정 + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(sp => sp); + _serviceProvider = services.BuildServiceProvider(); + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is IDisposable disposableServiceProvider) + { + disposableServiceProvider.Dispose(); + } + } + + private HttpContext CreateHttpContext() + { + return new DefaultHttpContext + { + RequestServices = _serviceProvider + }; + } + + [Test] + public async Task PostChatReportAsync_WithValidMessages_ShouldReturnOkResult() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "안녕하세요" }, + new() { Role = MessageRoleType.Assistant, Message = "안녕하세요! 자기소개 부탁드립니다." }, + new() { Role = MessageRoleType.User, Message = "저는 개발자입니다" } + }; + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "전반적으로 좋은 면접이었습니다.", + Strengths = ["명확한 의사소통", "적극적인 태도"], + Weaknesses = ["구체적 예시 부족"], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [1, 1, 1] + } + }; + + _kernelService.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + var result = await ChatReportDelegate.PostChatReportAsync(messages, _kernelService); + + // Assert + result.ShouldNotBeNull(); + + // HttpContext with proper ServiceProvider setup + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + + httpContext.Response.StatusCode.ShouldBe(200); + } + + [Test] + public async Task PostChatReportAsync_WithEmptyMessages_ShouldStillReturnOkResult() + { + // Arrange + var messages = new List(); + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "면접 기록이 부족합니다.", + Strengths = [], + Weaknesses = [], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [0, 0, 0] + } + }; + + _kernelService.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + var result = await ChatReportDelegate.PostChatReportAsync(messages, _kernelService); + + // Assert + result.ShouldNotBeNull(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + + httpContext.Response.StatusCode.ShouldBe(200); + } + + [Test] + public async Task PostChatReportAsync_WithKernelServiceException_ShouldThrowException() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + _kernelService.GenerateReportAsync(messages) + .Throws(new InvalidOperationException("커널 서비스 오류")); + + // Act & Assert + var exception = await Should.ThrowAsync( + async () => await ChatReportDelegate.PostChatReportAsync(messages, _kernelService)); + + exception.Message.ShouldBe("커널 서비스 오류"); + } + + [Test] + public async Task PostChatReportAsync_WithNullReport_ShouldReturnOkWithNull() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + _kernelService.GenerateReportAsync(messages).Returns(Task.FromResult(null)); + + // Act + var result = await ChatReportDelegate.PostChatReportAsync(messages, _kernelService); + + // Assert + result.ShouldNotBeNull(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + + httpContext.Response.StatusCode.ShouldBe(200); + } + + [Test] + public async Task PostChatReportAsync_ShouldCallKernelServiceOnce() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "테스트 리포트" + }; + + _kernelService.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + await ChatReportDelegate.PostChatReportAsync(messages, _kernelService); + + // Assert + await _kernelService.Received(1).GenerateReportAsync(messages); + } + + [Test] + public async Task PostChatReportAsync_WithLargeMessageList_ShouldProcessCorrectly() + { + // Arrange + var messages = new List(); + for (int i = 0; i < 100; i++) + { + messages.Add(new ChatMessage + { + Role = i % 2 == 0 ? MessageRoleType.User : MessageRoleType.Assistant, + Message = $"메시지 {i}" + }); + } + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "대화가 길었습니다.", + Strengths = ["인내심"], + Weaknesses = ["간결함 부족"], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [30, 30, 40] + } + }; + + _kernelService.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + var result = await ChatReportDelegate.PostChatReportAsync(messages, _kernelService); + + // Assert + result.ShouldNotBeNull(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + + httpContext.Response.StatusCode.ShouldBe(200); + await _kernelService.Received(1).GenerateReportAsync(messages); + } +} diff --git a/test/InterviewAssistant.ApiService.Tests/Services/KernelServiceReportTests.cs b/test/InterviewAssistant.ApiService.Tests/Services/KernelServiceReportTests.cs new file mode 100644 index 0000000..a303c3a --- /dev/null +++ b/test/InterviewAssistant.ApiService.Tests/Services/KernelServiceReportTests.cs @@ -0,0 +1,297 @@ +using System.Text.Json; +using InterviewAssistant.ApiService.Services; +using InterviewAssistant.ApiService.Repositories; +using InterviewAssistant.Common.Models; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Shouldly; + +namespace InterviewAssistant.ApiService.Tests.Services; + +[TestFixture] +public class KernelServiceReportTests +{ + private Kernel _kernel; + private IChatCompletionService _chatCompletionService; + private IMcpClient _mcpClient; + private IInterviewRepository _repository; + private KernelService _kernelService; + + [SetUp] + public void Setup() + { + // Create substitutes for interfaces only + _chatCompletionService = Substitute.For(); + _mcpClient = Substitute.For(); + _repository = Substitute.For(); + + // Create real kernel with mocked chat completion service + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(_chatCompletionService); + _kernel = builder.Build(); + + // Create kernel service + _kernelService = new KernelService(_kernel, _mcpClient, _repository); + } + + [TearDown] + public void TearDown() + { + // NUnit1032 경고를 해결하기 위한 TearDown + // IMcpClient가 IDisposable을 구현하는 경우에만 Dispose 호출 + if (_mcpClient is IDisposable disposableMcpClient) + { + disposableMcpClient.Dispose(); + } + } + + [Test] + public async Task GenerateReportAsync_WithValidMessages_ShouldReturnReport() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "안녕하세요, 면접 준비를 도와주세요" }, + new() { Role = MessageRoleType.Assistant, Message = "안녕하세요! 자기소개 부탁드립니다." }, + new() { Role = MessageRoleType.User, Message = "저는 3년차 개발자입니다" }, + new() { Role = MessageRoleType.Assistant, Message = "어떤 기술 스택을 주로 사용하시나요?" } + }; + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "전반적으로 좋은 면접이었습니다.", + Strengths = ["명확한 의사소통", "풍부한 경험", "기술적 이해도"], + Weaknesses = ["구체적 예시 부족", "질문 이해도", "답변 구조화"], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [2, 1, 1] + } + }; + + var expectedJsonResponse = JsonSerializer.Serialize(expectedReport); + + // Mock chat completion service to return expected JSON + _chatCompletionService + .GetChatMessageContentsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>( + [new ChatMessageContent(AuthorRole.Assistant, expectedJsonResponse)])); + + // Act + var result = await _kernelService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("전반적으로 좋은 면접이었습니다."); + result.Strengths.Count.ShouldBe(3); + result.Weaknesses.Count.ShouldBe(3); + result.ChartData.Labels.Count.ShouldBe(3); + result.ChartData.Values.Count.ShouldBe(3); + result.ChartData.Values.Sum().ShouldBe(4); + } + + [Test] + public async Task GenerateReportAsync_WithEmptyMessages_ShouldStillGenerateReport() + { + // Arrange + var messages = new List(); + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "면접 기록이 부족합니다.", + Strengths = [], + Weaknesses = [], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [0, 0, 0] + } + }; + + var expectedJsonResponse = JsonSerializer.Serialize(expectedReport); + + _chatCompletionService + .GetChatMessageContentsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>( + [new ChatMessageContent(AuthorRole.Assistant, expectedJsonResponse)])); + + // Act + var result = await _kernelService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("면접 기록이 부족합니다."); + } + + [Test] + public async Task GenerateReportAsync_WithInvalidJsonResponse_ShouldReturnErrorReport() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + // Mock invalid JSON response + _chatCompletionService + .GetChatMessageContentsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>( + [new ChatMessageContent(AuthorRole.Assistant, "잘못된 JSON 응답")])); + + // Act + var result = await _kernelService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("AI가 분석 결과를 잘못된 형식으로 반환했습니다. 잠시 후 다시 시도해 주세요."); + } + + [Test] + public async Task GenerateReportAsync_WithCodeBlockWrappedJson_ShouldParseCorrectly() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + var reportModel = new InterviewReportModel + { + OverallFeedback = "코드 블록으로 감싸진 응답 테스트", + Strengths = ["강점1"], + Weaknesses = ["약점1"], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [1, 0, 0] + } + }; + + var jsonResponse = JsonSerializer.Serialize(reportModel); + var wrappedResponse = $"```json\n{jsonResponse}\n```"; + + _chatCompletionService + .GetChatMessageContentsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>( + [new ChatMessageContent(AuthorRole.Assistant, wrappedResponse)])); + + // Act + var result = await _kernelService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("코드 블록으로 감싸진 응답 테스트"); + result.Strengths.Count.ShouldBe(1); + result.Weaknesses.Count.ShouldBe(1); + } + + [Test] + public async Task GenerateReportAsync_WithNullOrEmptyResponse_ShouldReturnErrorReport() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + _chatCompletionService + .GetChatMessageContentsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>( + [new ChatMessageContent(AuthorRole.Assistant, "")])); + + // Act + var result = await _kernelService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("AI로부터 응답을 받지 못했습니다."); + } + + [Test] + public async Task GenerateReportAsync_WithKernelException_ShouldReturnErrorReport() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + _chatCompletionService + .GetChatMessageContentsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Throws(new InvalidOperationException("커널 서비스 오류")); + + // Act + var result = await _kernelService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("리포트 생성 중 예상치 못한 오류가 발생했습니다."); + } + + [Test] + public async Task GenerateReportAsync_WithMultipleRoles_ShouldHandleAllRoles() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.System, Message = "시스템 메시지" }, + new() { Role = MessageRoleType.User, Message = "사용자 메시지" }, + new() { Role = MessageRoleType.Assistant, Message = "어시스턴트 메시지" }, + new() { Role = MessageRoleType.Tool, Message = "도구 메시지" } + }; + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "다양한 역할 테스트", + Strengths = [], + Weaknesses = [], + ChartData = new ChartDataModel { Labels = [], Values = [] } + }; + + var expectedJsonResponse = JsonSerializer.Serialize(expectedReport); + + _chatCompletionService + .GetChatMessageContentsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>( + [new ChatMessageContent(AuthorRole.Assistant, expectedJsonResponse)])); + + // Act + var result = await _kernelService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("다양한 역할 테스트"); + } +} diff --git a/test/InterviewAssistant.AppHost.Tests/Components/Pages/HomeTests.cs b/test/InterviewAssistant.AppHost.Tests/Components/Pages/HomeTests.cs index 09493de..8700ecd 100644 --- a/test/InterviewAssistant.AppHost.Tests/Components/Pages/HomeTests.cs +++ b/test/InterviewAssistant.AppHost.Tests/Components/Pages/HomeTests.cs @@ -346,7 +346,7 @@ public async Task Home_Serveroutput_Prohibit_UserTransport() $"Enter 전 메시지 수: {messageCountBeforeEnter}, Enter 후 메시지 수: {messageCountAfterEnter}"); } - /// + /// /// 중복 이벤트 방지 플래그가 제대로 동작하는지 확인합니다. /// [Test] @@ -357,10 +357,25 @@ public async Task Home_HandleKeyDown_PreventsDuplicateWithIsSendFlag() await Page.Locator("input#resumeUrl").FillAsync("https://example.com/resume.pdf"); await Page.Locator("input#jobUrl").FillAsync("https://example.com/job.pdf"); await Page.Locator("button.submit-btn").ClickAsync(); + + // 모달 닫힘 대기 await Page.WaitForSelectorAsync(".modal", new PageWaitForSelectorOptions { State = WaitForSelectorState.Detached, - Timeout = 5000 + Timeout = 10000 + }); + + // 초기 AI 응답 완료까지 대기 (다른 테스트와 동일하게) + await Page.WaitForSelectorAsync(".welcome-message", new PageWaitForSelectorOptions + { + State = WaitForSelectorState.Detached, + Timeout = 15000 + }); + + await Page.WaitForSelectorAsync(".response-status", new PageWaitForSelectorOptions + { + State = WaitForSelectorState.Detached, + Timeout = RESPONSE_TIMEOUT // 60초로 증가 }); // 초기 AI 응답 및 UI 준비 대기 @@ -377,6 +392,9 @@ public async Task Home_HandleKeyDown_PreventsDuplicateWithIsSendFlag() var textarea = Page.Locator("textarea#messageInput"); + // Textarea가 활성화될 때까지 명시적으로 대기 (타임아웃 증가) + await Expect(textarea).ToBeEnabledAsync(new() { Timeout = 20000 }); + // 초기 메시지 개수 확인 var initialMessageCount = await Page.EvaluateAsync("document.querySelectorAll('.message').length"); @@ -384,12 +402,12 @@ public async Task Home_HandleKeyDown_PreventsDuplicateWithIsSendFlag() await textarea.FillAsync("첫 번째 메시지"); await textarea.PressAsync("Enter"); - // 고정된 대기 시간으로 처리 완료 대기 - await Task.Delay(2000); + // 메시지 전송 후 충분한 대기 시간 + await Task.Delay(3000); var afterFirstSend = await Page.EvaluateAsync("document.querySelectorAll('.message').length"); - // Assert 1: 첫 번째 메시지 전송 확인 (메시지가 추가되었을 수도, 안 되었을 수도 있음) + // Assert 1: 첫 번째 메시지 전송 확인 Console.WriteLine($"초기 메시지 수: {initialMessageCount}, 첫 전송 후: {afterFirstSend}"); // Act 2: isSend 플래그를 true로 설정하여 중복 전송 차단 테스트 @@ -403,14 +421,17 @@ public async Task Home_HandleKeyDown_PreventsDuplicateWithIsSendFlag() var flagStatus = await Page.EvaluateAsync("window.isSend === true"); Console.WriteLine($"isSend 플래그 상태: {flagStatus}"); + // textarea가 여전히 활성화되어 있는지 재확인 + await Expect(textarea).ToBeEnabledAsync(new() { Timeout = 5000 }); + await textarea.PressAsync("Enter"); - await Task.Delay(1500); // 처리 시간 대기 + await Task.Delay(2000); // 처리 시간 대기 var afterBlockedSend = await Page.EvaluateAsync("document.querySelectorAll('.message').length"); Console.WriteLine($"차단 테스트 후 메시지 수: {afterBlockedSend}"); // Assert 2: isSend 플래그가 true일 때 메시지 전송 차단 확인 - afterBlockedSend.ShouldBe(afterFirstSend, "isSend 플래그가 true일 때 메시지 전송이 차단되어야 합니다_1."); + afterBlockedSend.ShouldBe(afterFirstSend, "isSend 플래그가 true일 때 메시지 전송이 차단되어야 합니다."); // Act 3: 플래그 해제 후 정상 전송 확인 await Page.EvaluateAsync("window.isSend = false;"); @@ -422,9 +443,12 @@ public async Task Home_HandleKeyDown_PreventsDuplicateWithIsSendFlag() // 새로운 메시지로 다시 시도 await textarea.ClearAsync(); await textarea.FillAsync("플래그 해제 후 메시지"); - await textarea.PressAsync("Enter"); - await Task.Delay(2000); // 처리 시간 대기 + // textarea 상태 재확인 + await Expect(textarea).ToBeEnabledAsync(new() { Timeout = 5000 }); + + await textarea.PressAsync("Enter"); + await Task.Delay(3000); // 처리 시간 대기 var afterFlagReset = await Page.EvaluateAsync("document.querySelectorAll('.message').length"); Console.WriteLine($"플래그 해제 후 메시지 수: {afterFlagReset}"); @@ -434,9 +458,9 @@ public async Task Home_HandleKeyDown_PreventsDuplicateWithIsSendFlag() var blockedCorrectly = (afterBlockedSend == afterFirstSend); var allowedAfterReset = (afterFlagReset >= afterBlockedSend); - blockedCorrectly.ShouldBeTrue("isSend 플래그가 true일 때 메시지 전송이 차단되어야 합니다_2."); + blockedCorrectly.ShouldBeTrue("isSend 플래그가 true일 때 메시지 전송이 차단되어야 합니다."); - // 만약 메시지 전송 기능이 실제로 작동한다면 + // 테스트 환경에서 실제 메시지 전송이 작동하는지 확인 if (afterFirstSend > initialMessageCount || afterFlagReset > afterBlockedSend) { Console.WriteLine("메시지 전송 기능이 작동하는 환경입니다."); @@ -450,4 +474,4 @@ public async Task Home_HandleKeyDown_PreventsDuplicateWithIsSendFlag() // 핵심 검증: 플래그가 true일 때와 false일 때의 동작 차이 Console.WriteLine($"테스트 결과 - 초기:{initialMessageCount}, 첫전송:{afterFirstSend}, 차단:{afterBlockedSend}, 해제:{afterFlagReset}"); } -} \ No newline at end of file +} diff --git a/test/InterviewAssistant.Web.Tests/Services/ChatServiceReportTests.cs b/test/InterviewAssistant.Web.Tests/Services/ChatServiceReportTests.cs new file mode 100644 index 0000000..425bc41 --- /dev/null +++ b/test/InterviewAssistant.Web.Tests/Services/ChatServiceReportTests.cs @@ -0,0 +1,235 @@ +using InterviewAssistant.Common.Models; +using InterviewAssistant.Web.Services; +using InterviewAssistant.Web.Clients; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; + +namespace InterviewAssistant.Web.Tests.Services; + +[TestFixture] +public class ChatServiceReportTests +{ + private IChatApiClient _apiClient; + private ILoggerFactory _loggerFactory; + private IChatService _chatService; + + [SetUp] + public void Setup() + { + _apiClient = Substitute.For(); + _loggerFactory = Substitute.For(); + _chatService = new ChatService(_apiClient, _loggerFactory); + } + + [TearDown] + public void Cleanup() + { + _loggerFactory?.Dispose(); + } + + [Test] + public async Task GenerateReportAsync_WithValidMessages_ShouldReturnReport() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "안녕하세요" }, + new() { Role = MessageRoleType.Assistant, Message = "안녕하세요! 자기소개 부탁드립니다." }, + new() { Role = MessageRoleType.User, Message = "저는 개발자입니다" } + }; + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "전반적으로 좋은 면접이었습니다.", + Strengths = ["명확한 의사소통", "적극적인 태도"], + Weaknesses = ["구체적 예시 부족"], + ChartData = new ChartDataModel + { + Labels = ["기술", "경험", "인성"], + Values = [1, 1, 1] + } + }; + + _apiClient.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + var result = await _chatService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("전반적으로 좋은 면접이었습니다."); + result.Strengths.Count.ShouldBe(2); + result.Weaknesses.Count.ShouldBe(1); + result.ChartData.Values.Sum().ShouldBe(3); + } + + [Test] + public async Task GenerateReportAsync_WithApiFailure_ShouldReturnNull() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + _apiClient.GenerateReportAsync(messages).Returns((InterviewReportModel?)null); + + // Act + var result = await _chatService.GenerateReportAsync(messages); + + // Assert + result.ShouldBeNull(); + } + + [Test] + public async Task GenerateReportAsync_ShouldStoreDataInService() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "테스트 리포트" + }; + + _apiClient.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + await _chatService.GenerateReportAsync(messages); + + // Assert + var storedHistory = _chatService.GetLastChatHistory(); + var storedReport = _chatService.GetLastReportSummary(); + + storedHistory.Count.ShouldBe(1); + storedHistory[0].Message.ShouldBe("테스트 메시지"); + storedReport.ShouldNotBeNull(); + storedReport.OverallFeedback.ShouldBe("테스트 리포트"); + } + + [Test] + public async Task GenerateReportAsync_WithNullReport_ShouldNotStoreData() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + _apiClient.GenerateReportAsync(messages).Returns((InterviewReportModel?)null); + + // Act + await _chatService.GenerateReportAsync(messages); + + // Assert + var storedHistory = _chatService.GetLastChatHistory(); + var storedReport = _chatService.GetLastReportSummary(); + + storedHistory.Count.ShouldBe(0); + storedReport.ShouldBeNull(); + } + + [Test] + public void GetLastChatHistory_InitialState_ShouldReturnEmptyList() + { + // Act + var result = _chatService.GetLastChatHistory(); + + // Assert + result.ShouldNotBeNull(); + result.Count.ShouldBe(0); + } + + [Test] + public void GetLastReportSummary_InitialState_ShouldReturnNull() + { + // Act + var result = _chatService.GetLastReportSummary(); + + // Assert + result.ShouldBeNull(); + } + + [Test] + public async Task GenerateReportAsync_ShouldCallApiClientOnce() + { + // Arrange + var messages = new List + { + new() { Role = MessageRoleType.User, Message = "테스트 메시지" } + }; + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "테스트 리포트" + }; + + _apiClient.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + await _chatService.GenerateReportAsync(messages); + + // Assert + await _apiClient.Received(1).GenerateReportAsync(messages); + } + + [Test] + public async Task GenerateReportAsync_WithEmptyMessages_ShouldStillCallApi() + { + // Arrange + var messages = new List(); + + var expectedReport = new InterviewReportModel + { + OverallFeedback = "면접 기록이 부족합니다." + }; + + _apiClient.GenerateReportAsync(messages).Returns(expectedReport); + + // Act + var result = await _chatService.GenerateReportAsync(messages); + + // Assert + result.ShouldNotBeNull(); + result.OverallFeedback.ShouldBe("면접 기록이 부족합니다."); + await _apiClient.Received(1).GenerateReportAsync(messages); + } + + [Test] + public async Task GenerateReportAsync_MultipleCallsOverwriteStoredData() + { + // Arrange + var firstMessages = new List + { + new() { Role = MessageRoleType.User, Message = "첫 번째 메시지" } + }; + + var secondMessages = new List + { + new() { Role = MessageRoleType.User, Message = "두 번째 메시지" } + }; + + var firstReport = new InterviewReportModel { OverallFeedback = "첫 번째 리포트" }; + var secondReport = new InterviewReportModel { OverallFeedback = "두 번째 리포트" }; + + _apiClient.GenerateReportAsync(firstMessages).Returns(firstReport); + _apiClient.GenerateReportAsync(secondMessages).Returns(secondReport); + + // Act + await _chatService.GenerateReportAsync(firstMessages); + await _chatService.GenerateReportAsync(secondMessages); + + // Assert + var storedHistory = _chatService.GetLastChatHistory(); + var storedReport = _chatService.GetLastReportSummary(); + + storedHistory.Count.ShouldBe(1); + storedHistory[0].Message.ShouldBe("두 번째 메시지"); + storedReport.ShouldNotBeNull(); + storedReport.OverallFeedback.ShouldBe("두 번째 리포트"); + } +}