Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<IResult> PostChatReportAsync(
[FromBody] List<ChatMessage> messages,
IKernelService kernelService)
{
var report = await kernelService.GenerateReportAsync(messages);
return Results.Ok(report);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ public static IEndpointRouteBuilder MapChatCompletionEndpoint(this IEndpointRout
.WithName("PostInterviewData")
.WithOpenApi();

// 면접 리포트 생성 엔드포인트
api.MapPost("report", ChatReportDelegate.PostChatReportAsync)
.Accepts<List<ChatMessage>>(contentType: "application/json")
.Produces<InterviewReportModel>(statusCode: StatusCodes.Status200OK, contentType: "application/json")
.WithName("PostChatReport")
.WithOpenApi();

return routeBuilder;
}
}
86 changes: 86 additions & 0 deletions src/InterviewAssistant.ApiService/Services/KernelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +21,8 @@ public interface IKernelService
{
IAsyncEnumerable<string> InvokeInterviewAgentAsync(string resumeContent, string jobDescriptionContent, IEnumerable<ChatMessageContent>? messages = null);
IAsyncEnumerable<string> PreprocessAndInvokeAsync(Guid resumeId, Guid jobId, string resumeUrl, string jobDescriptionUrl);

Task<InterviewReportModel> GenerateReportAsync(IEnumerable<Common.Models.ChatMessage> messages);
}

public class KernelService(Kernel kernel, IMcpClient mcpClient, IInterviewRepository repository) : IKernelService
Expand Down Expand Up @@ -153,4 +159,84 @@ private static string NormalizeUri(string uri)

return uri;
}

public async Task<InterviewReportModel> GenerateReportAsync(IEnumerable<Common.Models.ChatMessage> 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<string>(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<InterviewReportModel>(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 = "리포트 생성 중 예상치 못한 오류가 발생했습니다." };
}
}
}
21 changes: 21 additions & 0 deletions src/InterviewAssistant.Common/Models/InterviewReportModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace InterviewAssistant.Common.Models;

/// <summary>
/// 면접 결과 리포트의 데이터 구조
/// </summary>
public class InterviewReportModel
{
public string OverallFeedback { get; set; } = string.Empty;
public List<string> Strengths { get; set; } = [];
public List<string> Weaknesses { get; set; } = [];
public ChartDataModel ChartData { get; set; } = new();
}

/// <summary>
/// 차트 데이터 구조
/// </summary>
public class ChartDataModel
{
public List<string> Labels { get; set; } = [];
public List<int> Values { get; set; } = [];
}
18 changes: 17 additions & 1 deletion src/InterviewAssistant.Web/Clients/ChatApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public interface IChatApiClient
/// <param name="request">이력서 및 채용공고 URL이 포함된 요청 객체</param>
/// <returns>API 응답</returns>
IAsyncEnumerable<ChatResponse> SendInterviewDataAsync(InterviewDataRequest request);

Task<InterviewReportModel?> GenerateReportAsync(List<ChatMessage> messages);
}

/// <summary>
Expand Down Expand Up @@ -55,7 +57,7 @@ public async IAsyncEnumerable<ChatResponse> SendInterviewDataAsync(InterviewData
{
if (request == null)
throw new ArgumentNullException(nameof(request)); // 명시적 예외 처리

_logger.LogInformation("ChatApiClient.cs: 인터뷰 데이터 전송 시작");

var httpResponse = await _http.PostAsJsonAsync("/api/chat/interview-data", request);
Expand All @@ -72,4 +74,18 @@ public async IAsyncEnumerable<ChatResponse> SendInterviewDataAsync(InterviewData

_logger.LogInformation("ChatApiClient.cs: 인터뷰 데이터 전송 완료");
}

public async Task<InterviewReportModel?> GenerateReportAsync(List<ChatMessage> messages)
{
_logger.LogInformation("API에 리포트 생성 요청");
var httpResponse = await _http.PostAsJsonAsync("/api/chat/report", messages);

if (httpResponse.IsSuccessStatusCode)
{
return await httpResponse.Content.ReadFromJsonAsync<InterviewReportModel>();
}

_logger.LogError("리포트 생성 실패: {StatusCode}", httpResponse.StatusCode);
return null;
}
}
1 change: 1 addition & 0 deletions src/InterviewAssistant.Web/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="js/chatFunctions.js"></script>
</head>

Expand Down
23 changes: 23 additions & 0 deletions src/InterviewAssistant.Web/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
@inject IJSRuntime JSRuntime
@inject ILogger<Home> Logger
@rendermode InteractiveServer
@inject NavigationManager Navigation

<PageTitle>면접 코치 - InterviewAssistant</PageTitle>

Expand Down Expand Up @@ -41,6 +42,17 @@

<!-- 메인 채팅 영역 -->
<div class="chat-main">

<div class="chat-header">
<h2>AI 면접 코치</h2>
@if (isLinkShared && messages.Count > 2 && isServerOutputEnded)
{
<button class="btn btn-success btn-sm" @onclick="GoToReport" disabled="@(reportGenerating || isLoading)">
결과 보기
</button>
}
</div>

<!-- 채팅 메시지 영역 -->
<div class="chat-messages" id="chatMessages">
@if (!isLinkShared)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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");
}
}
108 changes: 108 additions & 0 deletions src/InterviewAssistant.Web/Components/Pages/Report.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
@page "/report"
@using InterviewAssistant.Web.Services
@using Markdig
@inject IChatService ChatService
@inject IJSRuntime JSRuntime
@rendermode InteractiveServer

<PageTitle>면접 결과 리포트</PageTitle>

<div class="report-container p-4">
<div class="text-center mb-4">
<h1>면접 결과 리포트</h1>
<p class="text-muted">AI가 분석한 면접 결과 요약입니다.</p>
</div>

<div class="row">
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5>종합 분석</h5>
</div>
<div class="card-body">
<p>@reportSummary?.OverallFeedback</p>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5>상세 분석</h5>
</div>
<div class="card-body">
<h6>👍 강점</h6>
<ul>
@foreach(var item in reportSummary?.Strengths ?? new())
{
<li>@item</li>
}
</ul>
<hr/>
<h6> 개선점</h6>
<ul>
@foreach(var item in reportSummary?.Weaknesses ?? new())
{
<li>@item</li>
}
</ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>질문 유형 분석</h5>
</div>
<div class="card-body">
<canvas id="typeChart"></canvas>
</div>
</div>
</div>
</div>

<div class="history-section mt-5">
<h3 class="text-center mb-3">전체 대화 기록</h3>
@foreach (var message in chatHistory)
{
<div class="message @(message.Role == MessageRoleType.User ? "user-message" : "bot-message")">
<div class="message-content">
@((MarkupString)Markdown.ToHtml(message.Message).Trim())
</div>
</div>
}
</div>
</div>

@code {
[Inject] private NavigationManager Navigation { get; set; } = default!;
private List<ChatMessage> chatHistory = new();
private InterviewReportModel? reportSummary;
private bool isInitialized = false;

Check warning on line 78 in src/InterviewAssistant.Web/Components/Pages/Report.razor

View workflow job for this annotation

GitHub Actions / build

The field 'Report.isInitialized' is assigned but its value is never used

Check warning on line 78 in src/InterviewAssistant.Web/Components/Pages/Report.razor

View workflow job for this annotation

GitHub Actions / build

The field 'Report.isInitialized' is assigned but its value is never used

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);
}
}
Loading