Add shared attachment context controls for AI job tools

This commit is contained in:
cesnimda
2026-03-23 22:30:54 +01:00
parent 6acd9f3e15
commit 05bc42c3d5
4 changed files with 59 additions and 35 deletions
@@ -1471,7 +1471,7 @@ namespace JobTrackerApi.Controllers
} }
[HttpGet("{id:int}/candidate-fit")] [HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken) public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{ {
var job = await _db.JobApplications var job = await _db.JobApplications
.Include(j => j.Company) .Include(j => j.Company)
@@ -1500,7 +1500,7 @@ namespace JobTrackerApi.Controllers
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 attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken); var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var jobContext = $@"Job title: {job.JobTitle} var jobContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name} Company: {job.Company?.Name}
Status: {job.Status} Status: {job.Status}
@@ -1594,7 +1594,7 @@ Candidate CV/profile:
} }
[HttpGet("{id:int}/focus-plan")] [HttpGet("{id:int}/focus-plan")]
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, CancellationToken cancellationToken) public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{ {
var job = await _db.JobApplications var job = await _db.JobApplications
.Include(j => j.Company) .Include(j => j.Company)
@@ -1623,7 +1623,7 @@ Candidate CV/profile:
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 attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken); var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var context = $@"Job title: {job.JobTitle} var context = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name} Company: {job.Company?.Name}
Status: {job.Status} Status: {job.Status}
@@ -1675,14 +1675,15 @@ Candidate master CV:
} }
[HttpGet("{id:int}/interview-prep")] [HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken) public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{ {
var job = await _db.JobApplications var job = await _db.JobApplications
.Include(j => j.Company) .Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); .FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound(); if (job is null) return NotFound();
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary } var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, attachmentContext?.Context }
.Where(x => !string.IsNullOrWhiteSpace(x))); .Where(x => !string.IsNullOrWhiteSpace(x)));
var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList(); var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList();
@@ -2047,7 +2048,7 @@ Candidate master CV:
} }
[HttpGet("{id:int}/followup-draft")] [HttpGet("{id:int}/followup-draft")]
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken) public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{ {
var job = await _db.JobApplications var job = await _db.JobApplications
.AsNoTracking() .AsNoTracking()
@@ -2083,7 +2084,7 @@ Candidate master CV:
: job.Status == "Rejected" ? "feedback-request" : job.Status == "Rejected" ? "feedback-request"
: "post-apply") : "post-apply")
: mode.Trim().ToLowerInvariant(); : mode.Trim().ToLowerInvariant();
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken); var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var aiContext = $@"Candidate name: {signerName} var aiContext = $@"Candidate name: {signerName}
Role: {job.JobTitle} Role: {job.JobTitle}
Company: {companyName} Company: {companyName}
+1
View File
@@ -81,5 +81,6 @@ Last updated: 2026-03-23
- [x] Simplify create-job workflow - [x] Simplify create-job workflow
- [x] Reduce duplicated UI/data across multiple pages - [x] Reduce duplicated UI/data across multiple pages
- [x] Add direct follow-up deep links for specific jobs - [x] Add direct follow-up deep links for specific jobs
- [x] Add attachment selection controls for AI-assisted job drafting tabs
- [ ] Perform final UX clarity pass across major screens - [ ] Perform final UX clarity pass across major screens
- [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages - [ ] Perform final consistency pass on labels, spacing, empty states, and feedback messages
@@ -115,6 +115,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [draftReloadToken, setDraftReloadToken] = useState(0); const [draftReloadToken, setDraftReloadToken] = useState(0);
const [draftSubject, setDraftSubject] = useState(""); const [draftSubject, setDraftSubject] = useState("");
const [draftBody, setDraftBody] = useState(""); const [draftBody, setDraftBody] = useState("");
const selectedAttachmentCsv = useMemo(() => selectedAttachmentIds.join(","), [selectedAttachmentIds]);
useEffect(() => { useEffect(() => {
if (!open || !jobId) return; if (!open || !jobId) return;
@@ -148,30 +149,37 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
useEffect(() => { useEffect(() => {
if (!open || !jobId || tab !== 4) return; if (!open || !jobId || tab !== 4) return;
setLoadingDraft(true); setLoadingDraft(true);
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode } }).then((r) => { api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
setFollowUpDraft(r.data); setFollowUpDraft(r.data);
setDraftSubject(r.data.subject); setDraftSubject(r.data.subject);
setDraftBody(r.data.body); setDraftBody(r.data.body);
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false)); }).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
}, [open, jobId, tab, followUpMode, draftReloadToken]); }, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv]);
useEffect(() => { useEffect(() => {
if (!open || !jobId || tab !== 5 || candidateFit) return; if (!open || !jobId || tab !== 5 || candidateFit) return;
setLoadingCandidateFit(true); setLoadingCandidateFit(true);
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
}, [open, jobId, tab, candidateFit]); }, [open, jobId, tab, candidateFit, selectedAttachmentCsv]);
useEffect(() => { useEffect(() => {
if (!open || !jobId || tab !== 6 || focusPlan) return; if (!open || !jobId || tab !== 6 || focusPlan) return;
setLoadingFocusPlan(true); setLoadingFocusPlan(true);
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false)); api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
}, [open, jobId, tab, focusPlan]); }, [open, jobId, tab, focusPlan, selectedAttachmentCsv]);
useEffect(() => { useEffect(() => {
if (!open || !jobId || tab !== 7 || interviewPrep) return; if (!open || !jobId || tab !== 7 || interviewPrep) return;
setLoadingInterviewPrep(true); setLoadingInterviewPrep(true);
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
}, [open, jobId, tab, interviewPrep]); }, [open, jobId, tab, interviewPrep, selectedAttachmentCsv]);
useEffect(() => {
setFollowUpDraft(null);
setCandidateFit(null);
setFocusPlan(null);
setInterviewPrep(null);
}, [selectedAttachmentCsv]);
useEffect(() => { useEffect(() => {
if (!open || !jobId || tab !== 8 || readiness) return; if (!open || !jobId || tab !== 8 || readiness) return;
@@ -203,6 +211,33 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const showTranslatedText = translatedDescriptionText.length > 0; const showTranslatedText = translatedDescriptionText.length > 0;
const showOriginalText = originalDescriptionText.length > 0; const showOriginalText = originalDescriptionText.length > 0;
const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]); const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]);
const showAiAttachmentPicker = tab >= 3 && tab <= 7 && jobAttachments.length > 0;
const attachmentPicker = showAiAttachmentPicker ? (
<Box sx={{ mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("jobDetailsAttachmentContextPicker")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button size="small" variant="text" onClick={() => setSelectedAttachmentIds(jobAttachments.slice(0, 4).map((item) => item.id))}>{t("jobDetailsAttachmentSelectTop")}</Button>
<Button size="small" variant="text" onClick={() => setSelectedAttachmentIds([])}>{t("jobDetailsAttachmentClear")}</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{jobAttachments.map((attachment) => {
const selected = selectedAttachmentIds.includes(attachment.id);
return (
<Chip
key={attachment.id}
label={attachment.fileName}
color={selected ? "primary" : "default"}
variant={selected ? "filled" : "outlined"}
onClick={() => setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))}
/>
);
})}
</Box>
</Box>
) : null;
return ( return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg"> <Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
@@ -234,6 +269,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null} {isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
</Tabs> </Tabs>
{attachmentPicker}
{tab === 0 && ( {tab === 0 && (
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box> <Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
@@ -353,25 +390,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box> </Box>
</Box> </Box>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography> <Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
{jobAttachments.length > 0 ? (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 0.75 }}>{t("jobDetailsAttachmentContextPicker")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{jobAttachments.map((attachment) => {
const selected = selectedAttachmentIds.includes(attachment.id);
return (
<Chip
key={attachment.id}
label={attachment.fileName}
color={selected ? "primary" : "default"}
variant={selected ? "filled" : "outlined"}
onClick={() => setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))}
/>
);
})}
</Box>
</Box>
) : null}
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} /> <TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography> <Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box> </Box>
+4
View File
@@ -695,6 +695,8 @@ export const translations = {
jobDetailsTailoredCvSaveFailed: "Failed to save tailored CV.", jobDetailsTailoredCvSaveFailed: "Failed to save tailored CV.",
jobDetailsTailoredCvIntro: "Generate a full application package, then edit and save the tailored resume you actually want to use for this role.", jobDetailsTailoredCvIntro: "Generate a full application package, then edit and save the tailored resume you actually want to use for this role.",
jobDetailsAttachmentContextPicker: "Use these attachments as AI context", jobDetailsAttachmentContextPicker: "Use these attachments as AI context",
jobDetailsAttachmentSelectTop: "Use recent files",
jobDetailsAttachmentClear: "Clear selection",
jobDetailsTailoredCvPlaceholder: "Paste or rewrite the version of your CV you want to use for this role.", jobDetailsTailoredCvPlaceholder: "Paste or rewrite the version of your CV you want to use for this role.",
jobDetailsLastUpdated: "Last updated: {value}", jobDetailsLastUpdated: "Last updated: {value}",
jobDetailsNotSavedYet: "Not saved yet", jobDetailsNotSavedYet: "Not saved yet",
@@ -1469,6 +1471,8 @@ export const translations = {
jobDetailsTailoredCvSaveFailed: "Kunne ikke lagre tilpasset CV.", jobDetailsTailoredCvSaveFailed: "Kunne ikke lagre tilpasset CV.",
jobDetailsTailoredCvIntro: "Generer en full søknadspakke, og rediger og lagre deretter den tilpassede CV-en du faktisk vil bruke for denne rollen.", jobDetailsTailoredCvIntro: "Generer en full søknadspakke, og rediger og lagre deretter den tilpassede CV-en du faktisk vil bruke for denne rollen.",
jobDetailsAttachmentContextPicker: "Bruk disse vedleggene som AI-kontekst", jobDetailsAttachmentContextPicker: "Bruk disse vedleggene som AI-kontekst",
jobDetailsAttachmentSelectTop: "Bruk nylige filer",
jobDetailsAttachmentClear: "Tøm utvalg",
jobDetailsTailoredCvPlaceholder: "Lim inn eller skriv om versjonen av CV-en du vil bruke for denne rollen.", jobDetailsTailoredCvPlaceholder: "Lim inn eller skriv om versjonen av CV-en du vil bruke for denne rollen.",
jobDetailsLastUpdated: "Sist oppdatert: {value}", jobDetailsLastUpdated: "Sist oppdatert: {value}",
jobDetailsNotSavedYet: "Ikke lagret ennå", jobDetailsNotSavedYet: "Ikke lagret ennå",