feat(cover-letter): add contextual AI workspace
This commit is contained in:
@@ -114,6 +114,55 @@ public sealed class AiWorkspaceTests
|
|||||||
Assert.Contains("Professional", res.Title);
|
Assert.Contains("Professional", res.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cover_letter_uses_the_linked_cv_current_draft_and_requested_language()
|
||||||
|
{
|
||||||
|
var (db, svc, ai) = New("user-1");
|
||||||
|
await using var _ = db;
|
||||||
|
var jobId = await SeedJobAsync(db, "user-1");
|
||||||
|
var profile = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Contact = new StructuredCvContact { FullName = "Ada Example" },
|
||||||
|
Jobs =
|
||||||
|
{
|
||||||
|
new StructuredCvJob { Id = "visible-role", Title = "Backend Developer", Company = "Initech", Bullets = { "Built truthful .NET services" } },
|
||||||
|
new StructuredCvJob { Id = "hidden-role", Title = "Unrelated role", Company = "Hidden Corp", Bullets = { "Unrelated work" } },
|
||||||
|
},
|
||||||
|
Skills = { "C#", ".NET" },
|
||||||
|
};
|
||||||
|
db.CareerProfiles.Add(new CareerProfile
|
||||||
|
{
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
ProfileJson = StructuredCvProfileJson.Serialize(profile),
|
||||||
|
});
|
||||||
|
db.CvVariants.Add(new CvVariant
|
||||||
|
{
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
JobApplicationId = jobId,
|
||||||
|
Name = "Backend CV",
|
||||||
|
PublicSlug = Guid.NewGuid().ToString("N"),
|
||||||
|
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings
|
||||||
|
{
|
||||||
|
Overrides = { ["hidden-role"] = new CvItemOverride { Hidden = true } },
|
||||||
|
}),
|
||||||
|
Version = 1,
|
||||||
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await svc.GenerateAsync("user-1", jobId, "master profile fallback", "Ada",
|
||||||
|
new AiGenerateRequest("cover-letter", "professional", null, "nb-NO", "Existing manual draft", "shorten"), "p", default);
|
||||||
|
|
||||||
|
Assert.Contains("CV BEING USED: Backend CV", ai.LastText);
|
||||||
|
Assert.Contains("Built truthful .NET services", ai.LastText);
|
||||||
|
Assert.DoesNotContain("Hidden Corp", ai.LastText);
|
||||||
|
Assert.Contains("CURRENT DOCUMENT TO REVISE", ai.LastText);
|
||||||
|
Assert.Contains("Existing manual draft", ai.LastText);
|
||||||
|
Assert.Contains("Norwegian Bokmål", ai.LastInstruction);
|
||||||
|
Assert.Contains("Shorten the current cover letter", ai.LastInstruction);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task History_is_newest_first_and_filters_by_module()
|
public async Task History_is_newest_first_and_filters_by_module()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,6 +92,22 @@ public sealed class InterviewAiContextTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task AttachCvAsync(JobTrackerContext db, string owner, int jobId)
|
||||||
|
{
|
||||||
|
db.CvVariants.Add(new CvVariant
|
||||||
|
{
|
||||||
|
OwnerUserId = owner,
|
||||||
|
JobApplicationId = jobId,
|
||||||
|
Name = "Backend CV",
|
||||||
|
PublicSlug = Guid.NewGuid().ToString("N"),
|
||||||
|
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings()),
|
||||||
|
Version = 1,
|
||||||
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private static AiGenerateRequest Interview() => new("interview", null, null);
|
private static AiGenerateRequest Interview() => new("interview", null, null);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -116,6 +132,7 @@ public sealed class InterviewAiContextTests
|
|||||||
await using var _ = db;
|
await using var _ = db;
|
||||||
var job = await SeedJobAsync(db, "user-1");
|
var job = await SeedJobAsync(db, "user-1");
|
||||||
await SeedProfileAsync(db, "user-1");
|
await SeedProfileAsync(db, "user-1");
|
||||||
|
await AttachCvAsync(db, "user-1", job.Id);
|
||||||
|
|
||||||
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
|
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
|
||||||
|
|
||||||
@@ -129,14 +146,14 @@ public sealed class InterviewAiContextTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Other_modules_are_unchanged()
|
public async Task Modules_without_application_intelligence_are_unchanged()
|
||||||
{
|
{
|
||||||
var (db, svc, ai) = New("user-1");
|
var (db, svc, ai) = New("user-1");
|
||||||
await using var _ = db;
|
await using var _ = db;
|
||||||
var job = await SeedJobAsync(db, "user-1");
|
var job = await SeedJobAsync(db, "user-1");
|
||||||
await SeedProfileAsync(db, "user-1");
|
await SeedProfileAsync(db, "user-1");
|
||||||
|
|
||||||
foreach (var module in new[] { "job-analysis", "career-match", "cover-letter", "application-review" })
|
foreach (var module in new[] { "job-analysis", "career-match", "application-review" })
|
||||||
{
|
{
|
||||||
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", new AiGenerateRequest(module, null, null), "test", default);
|
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", new AiGenerateRequest(module, null, null), "test", default);
|
||||||
Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText);
|
Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText);
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ public sealed class AiWorkspaceController : ControllerBase
|
|||||||
_usageScope = usageScope;
|
_usageScope = usageScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
|
public sealed record GenerateRequest(
|
||||||
|
string Module,
|
||||||
|
string? Mode,
|
||||||
|
string? ExtraContext,
|
||||||
|
string? TargetLanguage = null,
|
||||||
|
string? CurrentText = null,
|
||||||
|
string? Action = null);
|
||||||
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, int InputCharacterCount, int OutputCharacterCount, int EstimatedTokenCount, DateTimeOffset CreatedAtUtc);
|
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, int InputCharacterCount, int OutputCharacterCount, int EstimatedTokenCount, DateTimeOffset CreatedAtUtc);
|
||||||
|
|
||||||
[HttpGet("modules")]
|
[HttpGet("modules")]
|
||||||
@@ -76,7 +82,7 @@ public sealed class AiWorkspaceController : ControllerBase
|
|||||||
using var metering = _usageScope?.Suppress();
|
using var metering = _usageScope?.Suppress();
|
||||||
var interaction = await _workspace.GenerateAsync(
|
var interaction = await _workspace.GenerateAsync(
|
||||||
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
|
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
|
||||||
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct);
|
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext, request.TargetLanguage, request.CurrentText, request.Action), ResolveProvider(), ct);
|
||||||
if (interaction is null)
|
if (interaction is null)
|
||||||
{
|
{
|
||||||
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
|
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
public sealed record AiGenerateRequest(string Module, string? Mode, string? ExtraContext);
|
public sealed record AiGenerateRequest(
|
||||||
|
string Module,
|
||||||
|
string? Mode,
|
||||||
|
string? ExtraContext,
|
||||||
|
string? TargetLanguage = null,
|
||||||
|
string? CurrentText = null,
|
||||||
|
string? Action = null);
|
||||||
|
|
||||||
// Thrown when the AI service returns nothing usable — the controller maps it to 502 with the reason.
|
// Thrown when the AI service returns nothing usable — the controller maps it to 502 with the reason.
|
||||||
public sealed class AiUnavailableException : Exception
|
public sealed class AiUnavailableException : Exception
|
||||||
@@ -41,7 +47,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
|||||||
};
|
};
|
||||||
|
|
||||||
private const string Guardrail =
|
private const string Guardrail =
|
||||||
"Preserve every factual claim — never invent employers, titles, dates, qualifications, or metrics. "
|
"Preserve every factual claim — never invent employers, titles, dates, qualifications, technologies, achievements, years of experience, language ability, or metrics. "
|
||||||
|
+ "Do not claim enthusiasm for products the source does not show the candidate has used. Avoid corporate clichés and do not repeat the CV or advert verbatim. "
|
||||||
+ "This is a suggestion the user will review and edit; return only the requested content, in clean markdown, with no preamble.";
|
+ "This is a suggestion the user will review and edit; return only the requested content, in clean markdown, with no preamble.";
|
||||||
|
|
||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
@@ -111,15 +118,24 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
|||||||
if (job is null) return null;
|
if (job is null) return null;
|
||||||
|
|
||||||
var jobText = BuildJobContext(job);
|
var jobText = BuildJobContext(job);
|
||||||
var profile = string.IsNullOrWhiteSpace(profileText) ? "(no master profile on file yet)" : profileText.Trim();
|
var linkedCv = module is "cover-letter" or "career-match" or "interview"
|
||||||
|
? await BuildLinkedCvContextAsync(ownerUserId, jobApplicationId, candidateName, ct)
|
||||||
|
: null;
|
||||||
|
var profile = linkedCv?.Text ?? (string.IsNullOrWhiteSpace(profileText) ? "(no candidate profile on file yet)" : profileText.Trim());
|
||||||
var mode = NormalizeMode(module, req.Mode);
|
var mode = NormalizeMode(module, req.Mode);
|
||||||
var extra = string.IsNullOrWhiteSpace(req.ExtraContext) ? string.Empty : $"\n\nAdditional user context:\n{req.ExtraContext.Trim()}";
|
var extra = string.IsNullOrWhiteSpace(req.ExtraContext) ? string.Empty : $"\n\nAdditional user context:\n{req.ExtraContext.Trim()}";
|
||||||
|
var language = NormalizeLanguage(req.TargetLanguage);
|
||||||
|
var languageInstruction = language == "nb-NO"
|
||||||
|
? "Write in natural, professional Norwegian Bokmål (nb-NO)."
|
||||||
|
: "Write in natural, professional English.";
|
||||||
|
var currentDraft = string.IsNullOrWhiteSpace(req.CurrentText) ? string.Empty : $"\n\nCURRENT DOCUMENT TO REVISE:\n{req.CurrentText.Trim()}";
|
||||||
|
var action = NormalizeAction(req.Action);
|
||||||
|
|
||||||
// Interview prep is the module that benefits most from what the workspace already computed:
|
// Interview prep is the module that benefits most from what the workspace already computed:
|
||||||
// asking for likely questions without the requirements, the matched skills and — above all —
|
// asking for likely questions without the requirements, the matched skills and — above all —
|
||||||
// the gaps produces generic output. Everything here is deterministic and already on screen, so
|
// the gaps produces generic output. Everything here is deterministic and already on screen, so
|
||||||
// this adds context, not another AI call. Null when unavailable, and the prompt is unchanged.
|
// this adds context, not another AI call. Null when unavailable, and the prompt is unchanged.
|
||||||
var intelligence = module == "interview" && _intelligence is not null
|
var intelligence = module is "interview" or "cover-letter" && _intelligence is not null
|
||||||
? await BuildIntelligenceContextAsync(ownerUserId, jobApplicationId, ct)
|
? await BuildIntelligenceContextAsync(ownerUserId, jobApplicationId, ct)
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -127,8 +143,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
|||||||
{
|
{
|
||||||
"job-analysis" => (JobAnalysisPrompt(), jobText + extra, "Job analysis", 1000),
|
"job-analysis" => (JobAnalysisPrompt(), jobText + extra, "Job analysis", 1000),
|
||||||
"career-match" => (CareerMatchPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Career match", 1000),
|
"career-match" => (CareerMatchPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Career match", 1000),
|
||||||
"cover-letter" => (CoverLetterPrompt(mode!, candidateName), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", $"Cover letter · {Capitalize(mode!)}", 900),
|
"cover-letter" => (CoverLetterPrompt(mode!, candidateName, action, languageInstruction), $"CV BEING USED: {linkedCv?.Name ?? "Career profile"}\n{profile}\n\nJOB ADVERT:\n{jobText}{intelligence}{currentDraft}{extra}", $"Cover letter · {Capitalize(action)} · {Capitalize(mode!)}", 900),
|
||||||
"interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{intelligence}{extra}", "Interview prep", 1100),
|
"interview" => ($"{InterviewPrompt()} {languageInstruction}", $"CV BEING USED: {linkedCv?.Name ?? "Career profile"}\n{profile}\n\nJOB ADVERT:\n{jobText}{intelligence}{extra}", "Interview prep", 1100),
|
||||||
"application-review" => (ApplicationReviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Application review", 900),
|
"application-review" => (ApplicationReviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Application review", 900),
|
||||||
_ => throw new ArgumentException($"Unknown AI module '{module}'."),
|
_ => throw new ArgumentException($"Unknown AI module '{module}'."),
|
||||||
};
|
};
|
||||||
@@ -209,6 +225,51 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
|||||||
return CoverLetterModes.Contains(m) ? m : "professional";
|
return CoverLetterModes.Contains(m) ? m : "professional";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLanguage(string? language) =>
|
||||||
|
string.Equals(language?.Trim(), "nb", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(language?.Trim(), "nb-NO", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "nb-NO"
|
||||||
|
: "en";
|
||||||
|
|
||||||
|
private static string NormalizeAction(string? action)
|
||||||
|
{
|
||||||
|
var value = action?.Trim().ToLowerInvariant();
|
||||||
|
return value is "generate" or "regenerate" or "improve" or "shorten" or "expand" or "professional" or "natural" or "grammar" or "tailor"
|
||||||
|
? value
|
||||||
|
: "generate";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string Name, string Text)?> BuildLinkedCvContextAsync(string ownerUserId, int jobApplicationId, string candidateName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = _db.CvVariants.AsNoTracking()
|
||||||
|
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId);
|
||||||
|
var variant = _db.Database.IsSqlite()
|
||||||
|
? (await query.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc)
|
||||||
|
: await query.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct);
|
||||||
|
if (variant is null) return null;
|
||||||
|
|
||||||
|
var profileJson = await _db.CareerProfiles.AsNoTracking()
|
||||||
|
.Where(profile => profile.OwnerUserId == ownerUserId)
|
||||||
|
.Select(profile => profile.ProfileJson)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(profileJson)) return (variant.Name, "(the linked CV has no content yet)");
|
||||||
|
|
||||||
|
var profile = StructuredCvProfileJson.DeserializePersisted(profileJson);
|
||||||
|
var model = CvVariantResolver.Build(profile, CvVariantSettingsJson.Deserialize(variant.SettingsJson), candidateName, null);
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var section in model.Sections)
|
||||||
|
{
|
||||||
|
lines.Add($"## {section.Title}");
|
||||||
|
lines.AddRange(section.Bullets);
|
||||||
|
lines.AddRange(section.Tags);
|
||||||
|
lines.AddRange(section.SkillGroups.Select(group => $"{group.Name}: {string.Join(", ", group.Items)}"));
|
||||||
|
lines.AddRange(section.Entries.Select(entry =>
|
||||||
|
string.Join(" | ", new[] { entry.Title, entry.Subtitle, entry.Meta, string.Join("; ", entry.Bullets), string.Join(", ", entry.Tags) }
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||||
|
}
|
||||||
|
return (variant.Name, string.Join("\n", lines.Where(line => !string.IsNullOrWhiteSpace(line))));
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildJobContext(JobApplication job)
|
private static string BuildJobContext(JobApplication job)
|
||||||
{
|
{
|
||||||
var parts = new[]
|
var parts = new[]
|
||||||
@@ -242,9 +303,22 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
|||||||
+ "line of reasoning), **Strengths**, **Weaknesses**, **Missing skills**, **Most relevant experience**, and "
|
+ "line of reasoning), **Strengths**, **Weaknesses**, **Missing skills**, **Most relevant experience**, and "
|
||||||
+ "**Suggested improvements** (concrete, actionable). Base every point only on what the profile actually shows.";
|
+ "**Suggested improvements** (concrete, actionable). Base every point only on what the profile actually shows.";
|
||||||
|
|
||||||
private static string CoverLetterPrompt(string mode, string candidateName) =>
|
private static string CoverLetterPrompt(string mode, string candidateName, string action, string languageInstruction) =>
|
||||||
$"Write a cover letter for {(string.IsNullOrWhiteSpace(candidateName) ? "the candidate" : candidateName)} for this role in a "
|
$"{CoverLetterAction(action)} for {(string.IsNullOrWhiteSpace(candidateName) ? "the candidate" : candidateName)} for this role in a "
|
||||||
+ $"{ModeGuidance(mode)} Ground every claim in the candidate profile; do not invent experience. Return only the letter body.";
|
+ $"{ModeGuidance(mode)} {languageInstruction} Connect specific, supported experience to the job's requirements, acknowledge transferable experience honestly, and ground every claim in the linked CV. Return only the letter body.";
|
||||||
|
|
||||||
|
private static string CoverLetterAction(string action) => action switch
|
||||||
|
{
|
||||||
|
"regenerate" => "Write a fresh alternative cover letter",
|
||||||
|
"improve" => "Improve the clarity and impact of the current cover letter",
|
||||||
|
"shorten" => "Shorten the current cover letter while retaining its strongest evidence",
|
||||||
|
"expand" => "Add useful, supported detail to the current cover letter",
|
||||||
|
"professional" => "Make the current cover letter more professional",
|
||||||
|
"natural" => "Make the current cover letter sound more natural and human",
|
||||||
|
"grammar" => "Correct grammar and awkward phrasing in the current cover letter",
|
||||||
|
"tailor" => "Tailor the current cover letter more closely to the job requirements",
|
||||||
|
_ => "Write a cover letter",
|
||||||
|
};
|
||||||
|
|
||||||
private static string ModeGuidance(string mode) => mode switch
|
private static string ModeGuidance(string mode) => mode switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const COVER_LETTER_MODES = ["professional", "friendly", "short", "detaile
|
|||||||
export const aiWorkspaceApi = {
|
export const aiWorkspaceApi = {
|
||||||
usage: () => api.get<AiUsage>("/ai/usage").then((r) => r.data),
|
usage: () => api.get<AiUsage>("/ai/usage").then((r) => r.data),
|
||||||
modules: (jobId: number) => api.get<{ modules: string[]; provider: string }>(`/jobapplications/${jobId}/ai/modules`).then((r) => r.data),
|
modules: (jobId: number) => api.get<{ modules: string[]; provider: string }>(`/jobapplications/${jobId}/ai/modules`).then((r) => r.data),
|
||||||
generate: (jobId: number, body: { module: string; mode?: string; extraContext?: string }) =>
|
generate: (jobId: number, body: { module: string; mode?: string; extraContext?: string; targetLanguage?: "en" | "nb-NO"; currentText?: string; action?: string }) =>
|
||||||
api.post<AiInteraction>(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data),
|
api.post<AiInteraction>(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data),
|
||||||
history: (jobId: number, module?: string) =>
|
history: (jobId: number, module?: string) =>
|
||||||
api.get<AiInteraction[]>(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data),
|
api.get<AiInteraction[]>(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data),
|
||||||
|
|||||||
@@ -182,6 +182,48 @@ test("an empty cover letter offers the template and an empty history", async ()
|
|||||||
.toContain("Dear Hiring Manager");
|
.toContain("Dear Hiring Manager");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("AI cover letter suggestions use the linked CV and require explicit apply", async () => {
|
||||||
|
routeGet();
|
||||||
|
mockedApi.post.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 9,
|
||||||
|
module: "cover-letter",
|
||||||
|
mode: "professional",
|
||||||
|
title: "Cover letter · Generate · Professional",
|
||||||
|
provider: "local",
|
||||||
|
result: { text: "A tailored, truthful suggestion." },
|
||||||
|
createdAtUtc: "2026-07-19T11:00:00Z",
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||||
|
expect(await screen.findByText(/application's full job advert and analysis/i)).toBeInTheDocument();
|
||||||
|
const assistantSelects = screen.getAllByRole("combobox");
|
||||||
|
fireEvent.mouseDown(assistantSelects[assistantSelects.length - 1]);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Generate" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
||||||
|
"/jobapplications/7/ai/generate",
|
||||||
|
expect.objectContaining({
|
||||||
|
module: "cover-letter",
|
||||||
|
targetLanguage: "nb-NO",
|
||||||
|
currentText: "Dear team",
|
||||||
|
action: "generate",
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
expect(screen.getByLabelText("Cover letter")).toHaveValue("Dear team");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i }));
|
||||||
|
expect(screen.getByLabelText("Cover letter")).toHaveValue("A tailored, truthful suggestion.");
|
||||||
|
|
||||||
|
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||||
|
"/jobapplications/7/cover-letter",
|
||||||
|
{ text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate" },
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
test("a failed load surfaces an error", async () => {
|
test("a failed load surfaces an error", async () => {
|
||||||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
|
Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
|
||||||
Tooltip, Typography,
|
Skeleton, Stack, TextField, Tooltip, Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import RichTextField from "./RichTextField";
|
import RichTextField from "./RichTextField";
|
||||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||||
import RestoreIcon from "@mui/icons-material/Restore";
|
import RestoreIcon from "@mui/icons-material/Restore";
|
||||||
|
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||||
|
|
||||||
import { getApiErrorMessage } from "../api";
|
import { getApiErrorMessage } from "../api";
|
||||||
import {
|
import {
|
||||||
ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi,
|
ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi,
|
||||||
} from "../applicationWorkspace";
|
} from "../applicationWorkspace";
|
||||||
import { cvBuilderApi } from "../cvBuilder";
|
import { cvBuilderApi } from "../cvBuilder";
|
||||||
|
import { aiWorkspaceApi } from "../aiWorkspace";
|
||||||
|
import { useAccountPlan } from "../accountPlan";
|
||||||
|
|
||||||
// Phase 5.4 — Application Assets sections for the workspace.
|
// Phase 5.4 — Application Assets sections for the workspace.
|
||||||
//
|
//
|
||||||
@@ -267,6 +270,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
|||||||
[jobId],
|
[jobId],
|
||||||
);
|
);
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
|
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// The textarea is only seeded from the server until the user starts typing, so a reload never
|
// The textarea is only seeded from the server until the user starts typing, so a reload never
|
||||||
@@ -279,11 +283,12 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
|||||||
return () => onDirtyChange?.(false);
|
return () => onDirtyChange?.(false);
|
||||||
}, [dirty, onDirtyChange]);
|
}, [dirty, onDirtyChange]);
|
||||||
|
|
||||||
const save = async (value: string, source = "manual") => {
|
const save = async (value: string, source = "manual", aiAction?: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source));
|
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction));
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
setDraftAiAction(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getApiErrorMessage(err, "Could not save the cover letter."));
|
setError(getApiErrorMessage(err, "Could not save the cover letter."));
|
||||||
@@ -297,6 +302,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
|||||||
try {
|
try {
|
||||||
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
|
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
setDraftAiAction(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getApiErrorMessage(err, "Could not restore that version."));
|
setError(getApiErrorMessage(err, "Could not restore that version."));
|
||||||
@@ -320,14 +326,14 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
|||||||
value={text}
|
value={text}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={setDraft}
|
onChange={setDraft}
|
||||||
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
|
placeholder="Write it yourself, start from the template, or generate a tailored draft."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text)}>
|
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
|
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
|
||||||
Discard changes
|
Discard changes
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -343,6 +349,12 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|
||||||
|
<CoverLetterAiAssistant
|
||||||
|
jobId={jobId}
|
||||||
|
currentText={text}
|
||||||
|
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
|
||||||
|
/>
|
||||||
|
|
||||||
<Shell title="Version history" loading={loading} error={null}>
|
<Shell title="Version history" loading={loading} error={null}>
|
||||||
{(data?.versions.length ?? 0) === 0 ? (
|
{(data?.versions.length ?? 0) === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
@@ -391,6 +403,131 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COVER_LETTER_ACTIONS = [
|
||||||
|
{ key: "generate", label: "Generate" },
|
||||||
|
{ key: "regenerate", label: "Fresh alternative" },
|
||||||
|
{ key: "improve", label: "Improve" },
|
||||||
|
{ key: "shorten", label: "Shorten" },
|
||||||
|
{ key: "expand", label: "Add detail" },
|
||||||
|
{ key: "professional", label: "More professional" },
|
||||||
|
{ key: "natural", label: "More natural" },
|
||||||
|
{ key: "grammar", label: "Fix grammar" },
|
||||||
|
{ key: "tailor", label: "Tailor more closely" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
|
||||||
|
const { canUseAi } = useAccountPlan();
|
||||||
|
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
|
||||||
|
const [action, setAction] = useState("generate");
|
||||||
|
const [mode, setMode] = useState("professional");
|
||||||
|
const [language, setLanguage] = useState<"en" | "nb-NO">("en");
|
||||||
|
const [instructions, setInstructions] = useState("");
|
||||||
|
const [suggestion, setSuggestion] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await aiWorkspaceApi.generate(jobId, {
|
||||||
|
module: "cover-letter",
|
||||||
|
mode,
|
||||||
|
action,
|
||||||
|
targetLanguage: language,
|
||||||
|
currentText: currentText.trim() || undefined,
|
||||||
|
extraContext: instructions.trim() || undefined,
|
||||||
|
});
|
||||||
|
setSuggestion(result.result.text?.trim() ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
setError(getApiErrorMessage(err, "Could not generate a cover-letter suggestion."));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCv = !!cv?.attachedVariantId;
|
||||||
|
return (
|
||||||
|
<Shell
|
||||||
|
title="AI writing assistant"
|
||||||
|
subtitle="Uses this job and its linked CV. Suggestions never overwrite your document."
|
||||||
|
loading={loadingCv}
|
||||||
|
error={null}
|
||||||
|
>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{!hasCv ? (
|
||||||
|
<Alert severity="info">Select a CV before generating a tailored cover letter.</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert severity="success" variant="outlined" sx={{ py: 0.5 }}>
|
||||||
|
Using <strong>{cv?.attachedVariantName}</strong> and this application's full job advert and analysis.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
|
<InputLabel>Action</InputLabel>
|
||||||
|
<Select label="Action" value={action} onChange={(event) => setAction(event.target.value)}>
|
||||||
|
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Tone</InputLabel>
|
||||||
|
<Select label="Tone" value={mode} onChange={(event) => setMode(event.target.value)}>
|
||||||
|
{[
|
||||||
|
"professional", "friendly", "short", "detailed", "modern", "traditional",
|
||||||
|
].map((item) => <MenuItem key={item} value={item}>{item[0].toUpperCase() + item.slice(1)}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||||
|
<InputLabel>Document language</InputLabel>
|
||||||
|
<Select label="Document language" value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
||||||
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Additional instructions"
|
||||||
|
placeholder="For example: Focus on my .NET experience and keep it concise."
|
||||||
|
value={instructions}
|
||||||
|
onChange={(event) => setInstructions(event.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>Retry</Button>}>{error}</Alert>}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AutoFixHighIcon />}
|
||||||
|
disabled={!canUseAi || !hasCv || busy}
|
||||||
|
onClick={() => void generate()}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
{busy ? "Generating…" : canUseAi ? COVER_LETTER_ACTIONS.find((item) => item.key === action)?.label : "Pro required"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{suggestion && (
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}>
|
||||||
|
<Typography variant="overline" color="text.secondary">Current</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||||
|
{currentText || "No current draft"}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
|
||||||
|
<Typography variant="overline" color="primary">Suggestion</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||||
|
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>Apply to editor</Button>
|
||||||
|
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Application answer and recruiter message ----------
|
// ---------- Application answer and recruiter message ----------
|
||||||
|
|
||||||
type PackageDrafts = { applicationAnswer: string; recruiterMessage: string };
|
type PackageDrafts = { applicationAnswer: string; recruiterMessage: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user