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
35 changes: 35 additions & 0 deletions src/InterviewAssistant.ApiService/Delegates/PdfDownloadDelegate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using InterviewAssistant.Common.Models;
using InterviewAssistant.ApiService.Services;
using Microsoft.AspNetCore.Mvc;

namespace InterviewAssistant.ApiService.Delegates;

/// <summary>
/// PDF 다운로드를 처리하는 Delegate
/// </summary>
public static class PdfDownloadDelegate
{
/// <summary>
/// 면접 결과 리포트를 PDF로 다운로드합니다.
/// </summary>
/// <param name="request">PDF 생성 요청 데이터</param>
/// <param name="pdfService">PDF 생성 서비스</param>
/// <returns>PDF 파일</returns>
public static async Task<IResult> DownloadInterviewReportPdfAsync(
[FromBody] PdfDownloadRequest request,
IPdfGenerationService pdfService)
{
try
{
var pdfBytes = await pdfService.GenerateInterviewReportPdfAsync(request.Report, request.ChatHistory);

var fileName = $"면접결과_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";

return Results.File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
return Results.Problem($"PDF 생성 중 오류가 발생했습니다: {ex.Message}");
}
}
}
31 changes: 31 additions & 0 deletions src/InterviewAssistant.ApiService/Endpoints/PdfDownloadEndpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using InterviewAssistant.ApiService.Delegates;
using InterviewAssistant.Common.Models;

namespace InterviewAssistant.ApiService.Endpoints;

/// <summary>
/// PDF 다운로드 엔드포인트
/// </summary>
public static class PdfDownloadEndpoint
{
/// <summary>
/// PDF 다운로드 엔드포인트를 등록합니다.
/// </summary>
/// <param name="app">웹 애플리케이션</param>
/// <returns>라우트 그룹 빌더</returns>
public static RouteGroupBuilder MapPdfDownloadEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/pdf");

group.MapPost("/download-report", PdfDownloadDelegate.DownloadInterviewReportPdfAsync)
.WithName("DownloadInterviewReportPdf")
.WithDisplayName("Download Interview Report PDF")
.WithDescription("면접 결과 리포트를 PDF로 다운로드합니다.")
.WithOpenApi()
.Produces<byte[]>(200, "application/pdf")
.ProducesValidationProblem()
.Accepts<PdfDownloadRequest>("application/json");

return group;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
<PackageReference Include="Microsoft.SemanticKernel.Agents.core" Version="1.*" />
<PackageReference Include="Microsoft.SemanticKernel.Yaml" Version="1.*" />
<PackageReference Include="ModelContextProtocol" Version="0.*-*" />
<PackageReference Include="QuestPDF" Version="2024.*" />
<PackageReference Include="Markdig" Version="0.*" />
<PackageReference Include="SkiaSharp" Version="2.*" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 4 additions & 0 deletions src/InterviewAssistant.ApiService/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

builder.Services.AddScoped<IKernelService, KernelService>();
builder.Services.AddScoped<IInterviewRepository, InterviewRepository>();
builder.Services.AddScoped<IPdfGenerationService, PdfGenerationService>();

//OpenAPI 설정
builder.Services.AddOpenApi();
Expand Down Expand Up @@ -99,6 +100,9 @@
// Chat Completion 엔드포인트 매핑
app.MapChatCompletionEndpoint();

// PDF 다운로드 엔드포인트 매핑
app.MapPdfDownloadEndpoints();

// .NET Aspire 헬스체크 및 모니터링 엔드포인트 매핑
app.MapDefaultEndpoints();

Expand Down
194 changes: 194 additions & 0 deletions src/InterviewAssistant.ApiService/Services/PdfGenerationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using InterviewAssistant.Common.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using Markdig;
using System.Text.RegularExpressions;

namespace InterviewAssistant.ApiService.Services;

/// <summary>
/// PDF 생성 서비스 인터페이스
/// </summary>
public interface IPdfGenerationService
{
/// <summary>
/// 면접 결과 리포트를 PDF로 생성합니다.
/// </summary>
/// <param name="report">면접 결과 리포트</param>
/// <param name="chatHistory">채팅 기록</param>
/// <returns>PDF 바이트 배열</returns>
Task<byte[]> GenerateInterviewReportPdfAsync(InterviewReportModel report, List<ChatMessage> chatHistory);
}

/// <summary>
/// PDF 생성 서비스 구현
/// </summary>
public class PdfGenerationService : IPdfGenerationService
{
public async Task<byte[]> GenerateInterviewReportPdfAsync(InterviewReportModel report, List<ChatMessage> chatHistory)
{
// QuestPDF 라이선스 설정 (Community 라이선스)
QuestPDF.Settings.License = LicenseType.Community;

return await Task.FromResult(Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(2, Unit.Centimetre);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(11));

page.Header()
.Text("면접 결과 리포트")
.SemiBold().FontSize(20).FontColor(Colors.Blue.Medium);

page.Content()
.PaddingVertical(1, Unit.Centimetre)
.Column(column =>
{
column.Spacing(20);

// 종합 분석 (마크다운 처리)
column.Item().Text("종합 분석").SemiBold().FontSize(16);
var overallFeedbackHtml = ConvertMarkdownToPlainText(report.OverallFeedback ?? "분석 결과가 없습니다.");
column.Item().PaddingLeft(10).Text(overallFeedbackHtml);

// 강점 (마크다운 처리)
column.Item().Text("강점").SemiBold().FontSize(16);
if (report.Strengths?.Any() == true)
{
foreach (var strength in report.Strengths)
{
var strengthText = ConvertMarkdownToPlainText(strength);
column.Item().PaddingLeft(10).Text($"• {strengthText}");
}
}
else
{
column.Item().PaddingLeft(10).Text("• 강점이 기록되지 않았습니다.");
}

// 개선점 (마크다운 처리)
column.Item().Text("개선점").SemiBold().FontSize(16);
if (report.Weaknesses?.Any() == true)
{
foreach (var weakness in report.Weaknesses)
{
var weaknessText = ConvertMarkdownToPlainText(weakness);
column.Item().PaddingLeft(10).Text($"• {weaknessText}");
}
}
else
{
column.Item().PaddingLeft(10).Text("• 개선점이 기록되지 않았습니다.");
}

// 질문 유형 분석 (원 그래프 형태로 표시)
if (report.ChartData?.Labels?.Any() == true && report.ChartData?.Values?.Any() == true)
{
column.Item().Text("질문 유형 분석").SemiBold().FontSize(16);

// 시각적 차트 표현
column.Item().PaddingLeft(10).Column(chartColumn =>
{
var total = report.ChartData.Values.Sum();
if (total > 0)
{
chartColumn.Item().Text("📊 질문 유형별 분포").FontSize(12).SemiBold();
chartColumn.Item().PaddingVertical(5);

for (int i = 0; i < Math.Min(report.ChartData.Labels.Count, report.ChartData.Values.Count); i++)
{
var percentage = CalculatePercentage(report.ChartData.Values[i], total);
var barLength = (int)(percentage / 5); // 5%당 하나의 블록
var bar = new string('█', Math.Max(1, barLength));

chartColumn.Item().Row(row =>
{
row.ConstantItem(80).Text($"{report.ChartData.Labels[i]}:");
row.ConstantItem(150).Text(bar).FontColor(GetChartColor(i));
row.RelativeItem().Text($"{report.ChartData.Values[i]}개 ({percentage:F1}%)");
});
}
}
});
}

// 대화 기록 (마크다운 처리)
if (chatHistory?.Any() == true)
{
column.Item().Text("대화 기록").SemiBold().FontSize(16);

foreach (var message in chatHistory)
{
var roleText = message.Role switch
{
MessageRoleType.User => "지원자",
MessageRoleType.Assistant => "면접관",
_ => message.Role.ToString()
};

var messageText = ConvertMarkdownToPlainText(message.Message ?? "");

column.Item().PaddingLeft(10).Column(messageColumn =>
{
messageColumn.Item().Text($"[{roleText}]").SemiBold().FontColor(
message.Role == MessageRoleType.User ? Colors.Blue.Medium : Colors.Green.Medium);
messageColumn.Item().PaddingLeft(10).Text(messageText);
messageColumn.Item().PaddingVertical(5);
});
}
}
});

page.Footer()
.AlignCenter()
.Text(x =>
{
x.Span("생성일: ");
x.Span(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")).SemiBold();
});
});
}).GeneratePdf());
}

/// <summary>
/// 마크다운을 일반 텍스트로 변환합니다.
/// </summary>
private static string ConvertMarkdownToPlainText(string markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
return string.Empty;

// 마크다운을 HTML로 변환
var html = Markdown.ToHtml(markdown);

// HTML 태그 제거하여 일반 텍스트로 변환
var plainText = Regex.Replace(html, "<.*?>", string.Empty);

// HTML 엔티티 디코딩
plainText = System.Net.WebUtility.HtmlDecode(plainText);

return plainText.Trim();
}

/// <summary>
/// 차트 색상을 반환합니다.
/// </summary>
private static string GetChartColor(int index)
{
var colors = new[] { Colors.Blue.Medium, Colors.Green.Medium, Colors.Orange.Medium, Colors.Red.Medium, Colors.Purple.Medium };
return colors[index % colors.Length];
}

/// <summary>
/// 퍼센티지를 계산합니다.
/// </summary>
private static double CalculatePercentage(int value, int total)
{
if (total == 0) return 0;
return (double)value / total * 100;
}
}
17 changes: 17 additions & 0 deletions src/InterviewAssistant.Common/Models/PdfDownloadRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace InterviewAssistant.Common.Models;

/// <summary>
/// PDF 다운로드 요청 모델
/// </summary>
public class PdfDownloadRequest
{
/// <summary>
/// 면접 결과 리포트
/// </summary>
public InterviewReportModel Report { get; set; } = new();

/// <summary>
/// 채팅 기록
/// </summary>
public List<ChatMessage> ChatHistory { get; set; } = new();
}
29 changes: 29 additions & 0 deletions src/InterviewAssistant.Web/Clients/ChatApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ public interface IChatApiClient
IAsyncEnumerable<ChatResponse> SendInterviewDataAsync(InterviewDataRequest request);

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

/// <summary>
/// 면접 결과 리포트를 PDF로 다운로드합니다.
/// </summary>
/// <param name="report">면접 결과 리포트</param>
/// <param name="chatHistory">채팅 기록</param>
/// <returns>PDF 바이트 배열</returns>
Task<byte[]?> DownloadReportPdfAsync(InterviewReportModel report, List<ChatMessage> chatHistory);
}

/// <summary>
Expand Down Expand Up @@ -88,4 +96,25 @@ public async IAsyncEnumerable<ChatResponse> SendInterviewDataAsync(InterviewData
_logger.LogError("리포트 생성 실패: {StatusCode}", httpResponse.StatusCode);
return null;
}

public async Task<byte[]?> DownloadReportPdfAsync(InterviewReportModel report, List<ChatMessage> chatHistory)
{
_logger.LogInformation("API에 PDF 다운로드 요청");

var request = new PdfDownloadRequest
{
Report = report,
ChatHistory = chatHistory
};

var httpResponse = await _http.PostAsJsonAsync("/api/pdf/download-report", request);

if (httpResponse.IsSuccessStatusCode)
{
return await httpResponse.Content.ReadAsByteArrayAsync();
}

_logger.LogError("PDF 다운로드 실패: {StatusCode}", httpResponse.StatusCode);
return null;
}
}
Loading
Loading