diff --git a/src/InterviewCoach.Agent/InterviewCoach.Agent.csproj b/src/InterviewCoach.Agent/InterviewCoach.Agent.csproj
index a76d9c1..c784433 100644
--- a/src/InterviewCoach.Agent/InterviewCoach.Agent.csproj
+++ b/src/InterviewCoach.Agent/InterviewCoach.Agent.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/InterviewCoach.Agent/Program.cs b/src/InterviewCoach.Agent/Program.cs
index 5a4ade2..fe4ba7a 100644
--- a/src/InterviewCoach.Agent/Program.cs
+++ b/src/InterviewCoach.Agent/Program.cs
@@ -1,7 +1,13 @@
using System.Collections.Concurrent;
+using System.Text;
+using System.Text.Json;
using InterviewCoach.Agent;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
@@ -173,4 +179,98 @@
return Results.File(entry.Content, entry.ContentType, entry.FileName);
});
+// --- Export Endpoints ---
+app.MapGet("/export/{sessionId}/markdown", async (string sessionId) =>
+{
+ var mcpClient = app.Services.GetRequiredKeyedService("mcp-interview-data");
+ var result = await mcpClient.CallToolAsync("get_formatted_summary", new Dictionary
+ {
+ ["id"] = sessionId
+ });
+
+ var textBlock = result.Content.OfType().FirstOrDefault();
+ if (textBlock is null)
+ return Results.NotFound("Interview session not found.");
+
+ var markdown = textBlock.Text ?? string.Empty;
+ var bytes = Encoding.UTF8.GetBytes(markdown);
+
+ return Results.File(bytes, "text/markdown", $"interview-summary-{sessionId}.md");
+});
+
+app.MapGet("/export/{sessionId}/pdf", async (string sessionId) =>
+{
+ var mcpClient = app.Services.GetRequiredKeyedService("mcp-interview-data");
+ var result = await mcpClient.CallToolAsync("get_formatted_summary", new Dictionary
+ {
+ ["id"] = sessionId
+ });
+
+ var textBlock = result.Content.OfType().FirstOrDefault();
+ if (textBlock is null)
+ return Results.NotFound("Interview session not found.");
+
+ var markdown = textBlock.Text ?? string.Empty;
+ var lines = markdown.Split('\n');
+
+ QuestPDF.Settings.License = LicenseType.Community;
+
+ var pdfBytes = Document.Create(container =>
+ {
+ container.Page(page =>
+ {
+ page.Size(PageSizes.A4);
+ page.Margin(40);
+ page.DefaultTextStyle(x => x.FontSize(11));
+
+ page.Content().Column(column =>
+ {
+ foreach (var line in lines)
+ {
+ var trimmed = line.TrimEnd('\r');
+
+ if (trimmed.StartsWith("# "))
+ {
+ column.Item().PaddingBottom(10).Text(trimmed[2..])
+ .FontSize(22).Bold();
+ }
+ else if (trimmed.StartsWith("## "))
+ {
+ column.Item().PaddingTop(15).PaddingBottom(5).Text(trimmed[3..])
+ .FontSize(16).Bold();
+ }
+ else if (trimmed.StartsWith("**") && trimmed.EndsWith("**"))
+ {
+ column.Item().PaddingBottom(3).Text(trimmed.Trim('*'))
+ .Bold();
+ }
+ else if (trimmed == "---")
+ {
+ column.Item().PaddingVertical(8).LineHorizontal(0.5f).LineColor(Colors.Grey.Medium);
+ }
+ else if (trimmed.StartsWith("*") && trimmed.EndsWith("*"))
+ {
+ column.Item().PaddingBottom(3).Text(trimmed.Trim('*'))
+ .Italic().FontColor(Colors.Grey.Darken1);
+ }
+ else if (!string.IsNullOrWhiteSpace(trimmed))
+ {
+ column.Item().PaddingBottom(3).Text(trimmed);
+ }
+ }
+ });
+
+ page.Footer().AlignCenter().Text(text =>
+ {
+ text.Span("Page ");
+ text.CurrentPageNumber();
+ text.Span(" of ");
+ text.TotalPages();
+ });
+ });
+ }).GeneratePdf();
+
+ return Results.File(pdfBytes, "application/pdf", $"interview-summary-{sessionId}.pdf");
+});
+
await app.RunAsync();
diff --git a/src/InterviewCoach.Mcp.InterviewData/InterviewSessionRepository.cs b/src/InterviewCoach.Mcp.InterviewData/InterviewSessionRepository.cs
index ba53137..9c8f7b0 100644
--- a/src/InterviewCoach.Mcp.InterviewData/InterviewSessionRepository.cs
+++ b/src/InterviewCoach.Mcp.InterviewData/InterviewSessionRepository.cs
@@ -11,6 +11,7 @@ public interface IInterviewSessionRepository
Task GetInterviewSessionAsync(Guid id);
Task UpdateInterviewSessionAsync(InterviewSession interviewSession);
Task CompleteInterviewSessionAsync(Guid id);
+ Task GetFormattedSummaryAsync(Guid id);
}
public class InterviewSessionRepository(InterviewDataDbContext db) : IInterviewSessionRepository
@@ -91,4 +92,85 @@ await db.InterviewSessions.Where(p => p.Id == id)
return record;
}
+
+ public async Task GetFormattedSummaryAsync(Guid id)
+ {
+ var record = await db.InterviewSessions.SingleOrDefaultAsync(p => p.Id == id);
+ if (record is null)
+ {
+ return default;
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine("# Interview Session Summary");
+ sb.AppendLine();
+ sb.AppendLine($"**Session ID:** {record.Id}");
+ sb.AppendLine($"**Date:** {record.CreatedAt:yyyy-MM-dd HH:mm} UTC");
+ sb.AppendLine($"**Status:** {(record.IsCompleted ? "Completed" : "In Progress")}");
+ sb.AppendLine();
+
+ if (!string.IsNullOrWhiteSpace(record.ResumeLink))
+ {
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine("## Resume");
+ sb.AppendLine();
+ sb.AppendLine($"**Source:** {record.ResumeLink}");
+ if (!string.IsNullOrWhiteSpace(record.ResumeText))
+ {
+ sb.AppendLine();
+ sb.AppendLine(record.ResumeText.Trim());
+ }
+ sb.AppendLine();
+ }
+ else if (record.ProceedWithoutResume)
+ {
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine("## Resume");
+ sb.AppendLine();
+ sb.AppendLine("*Proceeded without resume.*");
+ sb.AppendLine();
+ }
+
+ if (!string.IsNullOrWhiteSpace(record.JobDescriptionLink))
+ {
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine("## Job Description");
+ sb.AppendLine();
+ sb.AppendLine($"**Source:** {record.JobDescriptionLink}");
+ if (!string.IsNullOrWhiteSpace(record.JobDescriptionText))
+ {
+ sb.AppendLine();
+ sb.AppendLine(record.JobDescriptionText.Trim());
+ }
+ sb.AppendLine();
+ }
+ else if (record.ProceedWithoutJobDescription)
+ {
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine("## Job Description");
+ sb.AppendLine();
+ sb.AppendLine("*Proceeded without job description.*");
+ sb.AppendLine();
+ }
+
+ if (!string.IsNullOrWhiteSpace(record.Transcript))
+ {
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine("## Interview Transcript");
+ sb.AppendLine();
+ sb.AppendLine(record.Transcript.Trim());
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine($"*Generated on {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm} UTC*");
+
+ return sb.ToString();
+ }
}
\ No newline at end of file
diff --git a/src/InterviewCoach.Mcp.InterviewData/InterviewSessionTool.cs b/src/InterviewCoach.Mcp.InterviewData/InterviewSessionTool.cs
index 7243910..204dff2 100644
--- a/src/InterviewCoach.Mcp.InterviewData/InterviewSessionTool.cs
+++ b/src/InterviewCoach.Mcp.InterviewData/InterviewSessionTool.cs
@@ -11,6 +11,7 @@ public interface IInterviewSessionTool
Task GetInterviewSessionAsync(Guid id);
Task UpdateInterviewSessionAsync(InterviewSession record);
Task CompleteInterviewSessionAsync(Guid id);
+ Task GetFormattedSummaryAsync(Guid id);
}
[McpServerToolType]
@@ -96,4 +97,23 @@ public async Task> GetAllInterviewSessionsAsync()
return completed;
}
+
+ [McpServerTool(Name = "get_formatted_summary", Title = "Get a formatted interview summary")]
+ [Description("Gets a formatted Markdown summary of a completed interview session, including session metadata, resume, job description, and full transcript.")]
+ public async Task GetFormattedSummaryAsync(
+ [Description("The ID of the interview session")] Guid id
+ )
+ {
+ var summary = await repository.GetFormattedSummaryAsync(id);
+ if (summary is null)
+ {
+ logger.LogWarning("Interview session with ID '{id}' not found.", id);
+
+ return default;
+ }
+
+ logger.LogInformation("Generated formatted summary for interview session '{id}'", id);
+
+ return summary;
+ }
}
diff --git a/src/InterviewCoach.WebUI/Components/Pages/Chat/Chat.razor b/src/InterviewCoach.WebUI/Components/Pages/Chat/Chat.razor
index 6977526..bb14334 100644
--- a/src/InterviewCoach.WebUI/Components/Pages/Chat/Chat.razor
+++ b/src/InterviewCoach.WebUI/Components/Pages/Chat/Chat.razor
@@ -4,6 +4,8 @@
@inject IChatClient ChatClient
@inject ILogger Logger
@inject FileUploadService FileUploadService
+@inject IHttpClientFactory HttpClientFactory
+@inject IJSRuntime JS
@implements IDisposable
Chat
@@ -17,6 +19,25 @@
+ @if (isInterviewComplete)
+ {
+
+
Download your interview summary:
+
+
+
+ }
+
@@ -36,6 +57,15 @@
private CancellationTokenSource? currentResponseCancellation;
private ChatMessage? currentResponseMessage;
private ChatInput? chatInput;
+ private bool isInterviewComplete;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/Chat.razor.js");
+ }
+ }
protected override void OnInitialized()
{
@@ -92,6 +122,20 @@ currentResponseCancellation.Token);
messages.Add(currentResponseMessage!);
statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0;
+
+ var assistantText = currentResponseMessage?.Text;
+ if (!isInterviewComplete && !string.IsNullOrEmpty(assistantText))
+ {
+ var lowerText = assistantText.ToLower();
+ if (lowerText.Contains("interview is complete") || lowerText.Contains("interview session complete")
+ || lowerText.Contains("completed your interview") || lowerText.Contains("session has been completed")
+ || lowerText.Contains("interview summary"))
+ {
+ isInterviewComplete = true;
+ StateHasChanged();
+ }
+ }
+
currentResponseMessage = null;
}
@@ -144,6 +188,7 @@ ChatMessage responseMessage, CancellationToken cancellationToken)
AddSessionSystemMessages();
chatOptions.ConversationId = sessionId;
statefulMessageCount = 0;
+ isInterviewComplete = false;
await chatInput!.FocusAsync();
}
@@ -176,6 +221,26 @@ ChatMessage responseMessage, CancellationToken cancellationToken)
return outboundMessages;
}
+ private async Task DownloadExportAsync(string format)
+ {
+ var client = HttpClientFactory.CreateClient("agent");
+ var url = $"export/{sessionId}/{format}";
+ var response = await client.GetAsync(url);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ Logger.LogWarning("Export failed with status {StatusCode}", response.StatusCode);
+ return;
+ }
+
+ var bytes = await response.Content.ReadAsByteArrayAsync();
+ var contentType = format == "pdf" ? "application/pdf" : "text/markdown";
+ var fileName = $"interview-summary-{sessionId}.{(format == "pdf" ? "pdf" : "md")}";
+
+ using var streamRef = new DotNetStreamReference(new MemoryStream(bytes));
+ await JS.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
+ }
+
public void Dispose()
=> currentResponseCancellation?.Cancel();
}
diff --git a/src/InterviewCoach.WebUI/Components/Pages/Chat/Chat.razor.js b/src/InterviewCoach.WebUI/Components/Pages/Chat/Chat.razor.js
new file mode 100644
index 0000000..401d3d8
--- /dev/null
+++ b/src/InterviewCoach.WebUI/Components/Pages/Chat/Chat.razor.js
@@ -0,0 +1,12 @@
+window.downloadFileFromStream = async (fileName, dotNetStreamRef) => {
+ const arrayBuffer = await dotNetStreamRef.arrayBuffer();
+ const blob = new Blob([arrayBuffer]);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+};