diff --git a/JobTrackerApi.Tests/AiWorkspaceTests.cs b/JobTrackerApi.Tests/AiWorkspaceTests.cs index 160329b..363a802 100644 --- a/JobTrackerApi.Tests/AiWorkspaceTests.cs +++ b/JobTrackerApi.Tests/AiWorkspaceTests.cs @@ -114,6 +114,55 @@ public sealed class AiWorkspaceTests 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] public async Task History_is_newest_first_and_filters_by_module() { diff --git a/JobTrackerApi.Tests/InterviewAiContextTests.cs b/JobTrackerApi.Tests/InterviewAiContextTests.cs index 1898032..1593f3e 100644 --- a/JobTrackerApi.Tests/InterviewAiContextTests.cs +++ b/JobTrackerApi.Tests/InterviewAiContextTests.cs @@ -92,6 +92,22 @@ public sealed class InterviewAiContextTests 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); [Fact] @@ -116,6 +132,7 @@ public sealed class InterviewAiContextTests await using var _ = db; var job = await SeedJobAsync(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); @@ -129,14 +146,14 @@ public sealed class InterviewAiContextTests } [Fact] - public async Task Other_modules_are_unchanged() + public async Task Modules_without_application_intelligence_are_unchanged() { var (db, svc, ai) = New("user-1"); await using var _ = db; var job = await SeedJobAsync(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); Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText); diff --git a/JobTrackerApi/Controllers/AiWorkspaceController.cs b/JobTrackerApi/Controllers/AiWorkspaceController.cs index c9cfcce..c4b567d 100644 --- a/JobTrackerApi/Controllers/AiWorkspaceController.cs +++ b/JobTrackerApi/Controllers/AiWorkspaceController.cs @@ -32,7 +32,13 @@ public sealed class AiWorkspaceController : ControllerBase _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); [HttpGet("modules")] @@ -76,7 +82,7 @@ public sealed class AiWorkspaceController : ControllerBase using var metering = _usageScope?.Suppress(); var interaction = await _workspace.GenerateAsync( 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 (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct); diff --git a/JobTrackerApi/Services/AiWorkspaceService.cs b/JobTrackerApi/Services/AiWorkspaceService.cs index 7be6207..16fe8cf 100644 --- a/JobTrackerApi/Services/AiWorkspaceService.cs +++ b/JobTrackerApi/Services/AiWorkspaceService.cs @@ -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(); + 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 { diff --git a/job-tracker-ui/src/aiWorkspace.ts b/job-tracker-ui/src/aiWorkspace.ts index 26a4025..ba4efdc 100644 --- a/job-tracker-ui/src/aiWorkspace.ts +++ b/job-tracker-ui/src/aiWorkspace.ts @@ -28,7 +28,7 @@ export const COVER_LETTER_MODES = ["professional", "friendly", "short", "detaile export const aiWorkspaceApi = { usage: () => api.get("/ai/usage").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(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data), history: (jobId: number, module?: string) => api.get(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data), diff --git a/job-tracker-ui/src/application-assets.test.tsx b/job-tracker-ui/src/application-assets.test.tsx index 520520a..254c92f 100644 --- a/job-tracker-ui/src/application-assets.test.tsx +++ b/job-tracker-ui/src/application-assets.test.tsx @@ -182,6 +182,48 @@ test("an empty cover letter offers the template and an empty history", async () .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(); + 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 () => { mockedApi.get.mockRejectedValue(new Error("boom")); diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index e71fcee..cdea7d4 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -1,18 +1,21 @@ import React, { useCallback, useEffect, useState } from "react"; import { - Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField, - Tooltip, Typography, + Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, + Skeleton, Stack, TextField, Tooltip, Typography, } from "@mui/material"; import RichTextField from "./RichTextField"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import RestoreIcon from "@mui/icons-material/Restore"; +import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import { getApiErrorMessage } from "../api"; import { ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi, } from "../applicationWorkspace"; import { cvBuilderApi } from "../cvBuilder"; +import { aiWorkspaceApi } from "../aiWorkspace"; +import { useAccountPlan } from "../accountPlan"; // Phase 5.4 — Application Assets sections for the workspace. // @@ -267,6 +270,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: [jobId], ); const [draft, setDraft] = useState(null); + const [draftAiAction, setDraftAiAction] = useState(null); const [busy, setBusy] = useState(false); // 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); }, [dirty, onDirtyChange]); - const save = async (value: string, source = "manual") => { + const save = async (value: string, source = "manual", aiAction?: string) => { setBusy(true); try { - setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source)); + setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction)); setDraft(null); + setDraftAiAction(null); setError(null); } catch (err) { setError(getApiErrorMessage(err, "Could not save the cover letter.")); @@ -297,6 +302,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: try { setData(await applicationAssetsApi.restoreCoverLetter(jobId, version)); setDraft(null); + setDraftAiAction(null); setError(null); } catch (err) { setError(getApiErrorMessage(err, "Could not restore that version.")); @@ -320,14 +326,14 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: value={text} disabled={busy} 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." /> - - }>{error}} + + + {suggestion && ( + + + Current + + {currentText || "No current draft"} + + + + Suggestion + {suggestion} + + + + + + + )} + + + ); +} + // ---------- Application answer and recruiter message ---------- type PackageDrafts = { applicationAnswer: string; recruiterMessage: string };