feat(cover-letter): add contextual AI workspace
This commit is contained in:
@@ -5,7 +5,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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.
|
||||
public sealed class AiUnavailableException : Exception
|
||||
@@ -41,7 +47,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
};
|
||||
|
||||
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.";
|
||||
|
||||
private readonly JobTrackerContext _db;
|
||||
@@ -111,15 +118,24 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
if (job is null) return null;
|
||||
|
||||
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 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:
|
||||
// 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
|
||||
// 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)
|
||||
: string.Empty;
|
||||
|
||||
@@ -127,8 +143,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
{
|
||||
"job-analysis" => (JobAnalysisPrompt(), jobText + extra, "Job analysis", 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),
|
||||
"interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{intelligence}{extra}", "Interview prep", 1100),
|
||||
"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()} {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),
|
||||
_ => throw new ArgumentException($"Unknown AI module '{module}'."),
|
||||
};
|
||||
@@ -209,6 +225,51 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
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)
|
||||
{
|
||||
var parts = new[]
|
||||
@@ -242,9 +303,22 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
+ "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.";
|
||||
|
||||
private static string CoverLetterPrompt(string mode, string candidateName) =>
|
||||
$"Write a cover letter 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.";
|
||||
private static string CoverLetterPrompt(string mode, string candidateName, string action, string languageInstruction) =>
|
||||
$"{CoverLetterAction(action)} for {(string.IsNullOrWhiteSpace(candidateName) ? "the candidate" : candidateName)} for this role in a "
|
||||
+ $"{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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user