Use structured CV sections in tailoring and test profile parsing
This commit is contained in:
@@ -56,6 +56,47 @@ namespace JobTrackerApi.Controllers
|
|||||||
return "Hi there,";
|
return "Hi there,";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record CvSectionRecord(string? Name, string? Content, int? WordCount);
|
||||||
|
|
||||||
|
private static string BuildStructuredCvContext(ApplicationUser? user)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user?.ProfileCvStructureJson)) return string.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sections = JsonSerializer.Deserialize<List<CvSectionRecord>>(user.ProfileCvStructureJson);
|
||||||
|
if (sections is null || sections.Count == 0) return string.Empty;
|
||||||
|
|
||||||
|
var preferredOrder = new[]
|
||||||
|
{
|
||||||
|
"Professional Summary",
|
||||||
|
"Core Skills",
|
||||||
|
"Experience Highlights",
|
||||||
|
"Selected Achievements",
|
||||||
|
"Projects",
|
||||||
|
"Education",
|
||||||
|
"Certifications",
|
||||||
|
};
|
||||||
|
|
||||||
|
var ordered = preferredOrder
|
||||||
|
.Select(name => sections.FirstOrDefault(section => string.Equals(section.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.Where(section => section is not null)
|
||||||
|
.Concat(sections.Where(section => !preferredOrder.Contains(section.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase)))
|
||||||
|
.Where(section => !string.IsNullOrWhiteSpace(section?.Content))
|
||||||
|
.Take(6)
|
||||||
|
.Select(section => $"{section!.Name}:\n{section.Content!.Trim()}")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return ordered.Count > 0
|
||||||
|
? $"Structured CV sections:\n{string.Join("\n\n", ordered)}"
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
||||||
{
|
{
|
||||||
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
||||||
@@ -1525,6 +1566,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
||||||
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
|
||||||
|
var structuredCvContext = BuildStructuredCvContext(user);
|
||||||
|
|
||||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
||||||
var jobContext = $@"Job title: {job.JobTitle}
|
var jobContext = $@"Job title: {job.JobTitle}
|
||||||
@@ -1535,7 +1577,7 @@ Job description and notes:
|
|||||||
{jobText}
|
{jobText}
|
||||||
|
|
||||||
Candidate CV/profile:
|
Candidate CV/profile:
|
||||||
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||||
|
|
||||||
var matchSummary = await _summarizer.SummarizeSectionAsync(
|
var matchSummary = await _summarizer.SummarizeSectionAsync(
|
||||||
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
|
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
|
||||||
@@ -1648,6 +1690,7 @@ Candidate CV/profile:
|
|||||||
var normalizedCv = cvText.ToLowerInvariant();
|
var normalizedCv = cvText.ToLowerInvariant();
|
||||||
var matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
|
var matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
|
||||||
var missingTags = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
|
var missingTags = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
|
||||||
|
var structuredCvContext = BuildStructuredCvContext(user);
|
||||||
|
|
||||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
||||||
var context = $@"Job title: {job.JobTitle}
|
var context = $@"Job title: {job.JobTitle}
|
||||||
@@ -1657,7 +1700,7 @@ Job description and notes:
|
|||||||
{jobText}
|
{jobText}
|
||||||
|
|
||||||
Candidate master CV:
|
Candidate master CV:
|
||||||
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||||
|
|
||||||
var strategicSummary = await _summarizer.SummarizeSectionAsync(
|
var strategicSummary = await _summarizer.SummarizeSectionAsync(
|
||||||
"Write a concise strategy summary for how the candidate should approach this role. Focus on what matters most in the posting, what evidence to lead with, and where to be careful.",
|
"Write a concise strategy summary for how the candidate should approach this role. Focus on what matters most in the posting, what evidence to lead with, and where to be careful.",
|
||||||
@@ -1824,6 +1867,7 @@ Candidate master CV:
|
|||||||
|
|
||||||
var packageModeInstruction = BuildPackageModeInstruction(mode);
|
var packageModeInstruction = BuildPackageModeInstruction(mode);
|
||||||
var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle);
|
var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle);
|
||||||
|
var structuredCvContext = BuildStructuredCvContext(user);
|
||||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
||||||
|
|
||||||
var packageContext = $@"Job title: {job.JobTitle}
|
var packageContext = $@"Job title: {job.JobTitle}
|
||||||
@@ -1836,7 +1880,7 @@ Job context:
|
|||||||
{jobText}
|
{jobText}
|
||||||
|
|
||||||
Candidate master CV:
|
Candidate master CV:
|
||||||
{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
||||||
|
|
||||||
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
|
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
|
||||||
$"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job. {packageModeInstruction}",
|
$"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job. {packageModeInstruction}",
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { ToastProvider } from './toast';
|
||||||
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
|
import ProfilePage from './pages/ProfilePage';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
patch: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./components/GoogleAuthCard', () => () => null);
|
||||||
|
jest.mock('./components/CropImageDialog', () => () => null);
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<ProfilePage />
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/auth/me') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
provider: 'local',
|
||||||
|
email: 'demo@example.com',
|
||||||
|
userName: 'demo',
|
||||||
|
firstName: 'Demo',
|
||||||
|
lastName: 'User',
|
||||||
|
displayName: 'Demo User',
|
||||||
|
profileCvText: 'Professional Summary\nBuilt backend systems',
|
||||||
|
profileCvStructureJson: JSON.stringify([
|
||||||
|
{ name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 },
|
||||||
|
]),
|
||||||
|
googleLink: { linked: false },
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
mockedApi.post.mockImplementation((url: string) => {
|
||||||
|
if (url === '/profile-cv/parse') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
sections: [
|
||||||
|
{ name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 },
|
||||||
|
{ name: 'Core Skills', content: '.NET\nSQL\nAzure', wordCount: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile page loads persisted cv sections and can re-parse them', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const analyzeButton = screen.getByRole('button', { name: /analyze sections/i });
|
||||||
|
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
||||||
|
fireEvent.click(analyzeButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/parse', { text: 'Professional Summary\nBuilt backend systems' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText(/core skills/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving profile persists structured cv json', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||||
|
const saveButton = screen.getByRole('button', { name: /save changes/i });
|
||||||
|
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.put).toHaveBeenCalledWith('/auth/profile', expect.objectContaining({
|
||||||
|
profileCvStructureJson: JSON.stringify([
|
||||||
|
{ name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user