From 0cacb4e51ba3955938a2e7ba0ef83ba92548ff65 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 24 Mar 2026 11:05:41 +0100 Subject: [PATCH] Implement S03 follow-up draft context loop --- .gsd/milestones/M001/slices/S03/S03-PLAN.md | 60 +++++++ .../M001/slices/S03/tasks/T01-PLAN.md | 50 ++++++ .../M001/slices/S03/tasks/T01-SUMMARY.md | 28 ++++ .../M001/slices/S03/tasks/T02-PLAN.md | 52 ++++++ .../M001/slices/S03/tasks/T02-SUMMARY.md | 31 ++++ .../JobApplicationsFollowUpDraftTests.cs | 148 ++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 140 +++++++++++++++-- .../src/components/JobDetailsDialog.tsx | 31 ++-- .../src/job-details-followup-drafts.test.tsx | 119 ++++++++++++++ job-tracker-ui/src/types.ts | 12 ++ 10 files changed, 645 insertions(+), 26 deletions(-) create mode 100644 .gsd/milestones/M001/slices/S03/S03-PLAN.md create mode 100644 .gsd/milestones/M001/slices/S03/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M001/slices/S03/tasks/T01-SUMMARY.md create mode 100644 .gsd/milestones/M001/slices/S03/tasks/T02-PLAN.md create mode 100644 .gsd/milestones/M001/slices/S03/tasks/T02-SUMMARY.md create mode 100644 JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs create mode 100644 job-tracker-ui/src/job-details-followup-drafts.test.tsx diff --git a/.gsd/milestones/M001/slices/S03/S03-PLAN.md b/.gsd/milestones/M001/slices/S03/S03-PLAN.md new file mode 100644 index 0000000..c93edf4 --- /dev/null +++ b/.gsd/milestones/M001/slices/S03/S03-PLAN.md @@ -0,0 +1,60 @@ +# S03: Reply and follow-up drafting from real thread context + +**Goal:** Make follow-up drafting use imported correspondence and saved application material well enough that the job workspace can produce specific, trustworthy follow-up and reply drafts without crossing the manual-send boundary. +**Demo:** From a job with imported Gmail correspondence and saved package material, the user opens the Follow-up tab, generates a draft that clearly reflects the thread stage plus saved job context, edits it, and manually sends/logs it while keeping outbound control. + +S03 directly owns active requirements **R004**, **R007**, and **R008**. The main risk is that the existing follow-up draft endpoint still behaves like a generic template generator: it knows basic job facts, but it does not deliberately consume imported thread details or the saved package material that now exists after S02. This slice should strengthen that backend context first, then make the workspace expose the richer draft state clearly without adding autonomous sending behavior or a second competing draft surface. + +## Must-Haves + +- Follow-up draft generation in `JobTrackerApi/Controllers/JobApplicationsController.cs` uses imported correspondence, recruiter details, and saved application package material deliberately enough that the draft responds to the real thread stage instead of generic reminders. +- The job workspace in `job-tracker-ui/src/components/JobDetailsDialog.tsx` presents follow-up drafting as job-tied working context, including visible thread/package grounding and editable draft state before manual send. +- The slice preserves the explicit manual-send boundary: the app may draft and log what was sent, but it must not auto-send or silently dispatch anything. + +## Proof Level + +- This slice proves: integration +- Real runtime required: yes +- Human/UAT required: yes + +## Verification + +- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsFollowUpDraftTests` +- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-followup-drafts.test.tsx` +- Manual UAT: open a job with imported correspondence and saved package material, generate a follow-up draft, confirm it references the real job/thread/package context, edit the draft, send/log it manually, and verify the job timeline reflects the sent message without any autonomous outbound behavior. + +## Observability / Diagnostics + +- Runtime signals: follow-up draft responses should expose why the draft was generated now, what thread/package context informed it, and sent follow-ups should continue to create correspondence timeline entries. +- Inspection surfaces: `GET /api/jobapplications/{id}/followup-draft`, `POST /api/jobapplications/{id}/send-followup`, `GET /api/correspondence/{jobId}`, and the Follow-up tab in `job-tracker-ui/src/components/JobDetailsDialog.tsx`. +- Failure visibility: focused backend tests should make missing-context and weak-context behavior obvious, while the workspace should distinguish generation failure, editable draft state, and sent/logged state. +- Redaction constraints: imported message bodies and recruiter details stay inside application data flows; no new diagnostic logging should expose raw correspondence or secrets. + +## Integration Closure + +- Upstream surfaces consumed: imported correspondence in `Models/Correspondence.cs` and `JobTrackerApi/Controllers/CorrespondenceController.cs`, saved package material in `JobTrackerApi/Controllers/JobApplicationsController.cs`, and the current follow-up workspace in `job-tracker-ui/src/components/JobDetailsDialog.tsx`. +- New wiring introduced in this slice: correspondence-aware follow-up context assembly plus a clearer job-workspace follow-up loop grounded in saved package material and thread state. +- What remains before the milestone is truly usable end-to-end: S04 still needs to improve the daily control surfaces, and S05 still needs full live-loop revalidation across import, drafting, follow-up, and tracking. + +## Tasks + +- [x] **T01: Strengthen follow-up draft context assembly and backend reply/follow-up tests** `est:4h` + - Why: The slice succeeds or fails on draft quality and trust, so the generator must consume imported thread context plus saved package material before the workspace polish matters. + - Files: `JobTrackerApi/Controllers/JobApplicationsController.cs`, `JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs` + - Do: Audit `GetFollowUpDraft` and related helpers, add deliberate use of imported correspondence metadata/content, recruiter details, saved tailored CV / cover letter / recruiter message material, and explicit thread-stage cues; keep the manual-send boundary intact; and add focused backend tests proving the returned draft reflects real thread and package context instead of generic follow-up text. + - Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsFollowUpDraftTests` + - Done when: follow-up draft generation clearly uses job/thread/package context and focused backend tests prove the response is materially more specific. +- [x] **T02: Make the follow-up workspace show thread-grounded draft state without autonomous sending** `est:4h` + - Why: Even a stronger backend draft will still feel fragile if the Follow-up tab hides what context informed it or behaves like a blind mail form. + - Files: `job-tracker-ui/src/components/JobDetailsDialog.tsx`, `job-tracker-ui/src/types.ts`, `job-tracker-ui/src/job-details-followup-drafts.test.tsx` + - Do: Refine the Follow-up tab so the user can see why a draft was generated now, what saved/job/thread context informed it, edit the draft before sending, and observe the manual-send boundary clearly; expand focused frontend coverage for generate/edit/send/log behavior. + - Verify: `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-followup-drafts.test.tsx` + - Done when: the Follow-up tab presents a grounded editable draft loop tied to the job and the focused frontend test proves generation, editability, and manual send/log behavior. + +## Files Likely Touched + +- `JobTrackerApi/Controllers/JobApplicationsController.cs` +- `JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs` +- `job-tracker-ui/src/components/JobDetailsDialog.tsx` +- `job-tracker-ui/src/types.ts` +- `job-tracker-ui/src/job-details-followup-drafts.test.tsx` diff --git a/.gsd/milestones/M001/slices/S03/tasks/T01-PLAN.md b/.gsd/milestones/M001/slices/S03/tasks/T01-PLAN.md new file mode 100644 index 0000000..1206899 --- /dev/null +++ b/.gsd/milestones/M001/slices/S03/tasks/T01-PLAN.md @@ -0,0 +1,50 @@ +--- +estimated_steps: 4 +estimated_files: 2 +skills_used: + - test +--- + +# T01: Strengthen follow-up draft context assembly and backend reply/follow-up tests + +**Slice:** S03 — Reply and follow-up drafting from real thread context +**Milestone:** M001 + +## Description + +Make the follow-up draft endpoint use imported correspondence, recruiter details, and saved application package material so the draft reflects the real thread stage and saved job context instead of a generic reminder template. + +## Steps + +1. Audit `GetFollowUpDraft` and nearby helpers in `JobApplicationsController` to identify which imported thread/package signals are currently ignored. +2. Add or refactor backend context assembly so follow-up drafting consumes recent correspondence, saved package material, recruiter details, and stage-specific cues without crossing the manual-send boundary. +3. Add focused backend tests proving the follow-up draft output changes in response to thread/package context and still preserves explicit manual-send behavior. +4. Verify the focused backend behavior with an isolated test path if the broader test project remains blocked by unrelated compile drift. + +## Must-Haves + +- [ ] Follow-up draft context includes imported correspondence and saved application package material deliberately. +- [ ] The generated follow-up draft reflects thread stage/recruiter context instead of generic job-only phrasing. +- [ ] Focused backend tests prove the stronger draft grounding. + +## Verification + +- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsFollowUpDraftTests` +- Focused isolated harness if needed: run only `JobApplicationsFollowUpDraftTests` against `JobTrackerApi/Controllers/JobApplicationsController.cs` + +## Observability Impact + +- Signals added/changed: richer follow-up draft reason/context surface and clearer thread-aware draft behavior. +- How a future agent inspects this: `GET /api/jobapplications/{id}/followup-draft` plus `JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs`. +- Failure state exposed: missing thread/package context should show up as weaker fallback behavior in focused backend tests rather than silent generic output. + +## Inputs + +- `JobTrackerApi/Controllers/JobApplicationsController.cs` — existing follow-up draft and send/log endpoints. +- `Models/Correspondence.cs` — imported thread/sender/recipient fields from S01. +- `JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs` — current focused test seam style for isolated job-application behavior. + +## Expected Output + +- `JobTrackerApi/Controllers/JobApplicationsController.cs` — stronger follow-up draft context assembly. +- `JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs` — focused backend proof for thread-aware follow-up drafting. diff --git a/.gsd/milestones/M001/slices/S03/tasks/T01-SUMMARY.md b/.gsd/milestones/M001/slices/S03/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..16512f3 --- /dev/null +++ b/.gsd/milestones/M001/slices/S03/tasks/T01-SUMMARY.md @@ -0,0 +1,28 @@ +--- +title: T01 summary +status: done +files: + - JobTrackerApi/Controllers/JobApplicationsController.cs + - JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs +verification: + - $HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj + - docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 bash -lc '...dotnet test /tmp/followuptests/FollowUpTests.csproj...' +--- + +Strengthened follow-up draft generation so it now consumes imported thread context and saved application package material instead of relying mostly on job summary text. + +What changed: +- `JobTrackerApi/Controllers/JobApplicationsController.cs` + - expanded `FollowUpDraftDto` to expose context summary, context signals, thread subject, and last-correspondence metadata for the workspace + - added helpers to parse the saved application-answer draft from notes, derive reply-style subjects from the latest thread, and assemble follow-up context signals from recruiter/package/thread state + - enriched `GetFollowUpDraft(...)` so the AI prompt now includes imported correspondence context, recruiter details, saved tailored CV / cover letter / recruiter message / application-answer material, and thread-stage cues + - improved the fallback body so it still reflects saved/thread context when AI output is unavailable +- `JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs` + - added focused backend proof that follow-up draft generation reflects imported thread and saved package state + +Verification: +- Backend host build passed with `$HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj` +- Focused follow-up draft backend test passed in an isolated Docker harness (`1 passed`) + +Important caveat: +- The broader `JobTrackerApi.Tests` project still has unrelated compile drift, so focused follow-up verification was isolated rather than run through the full test project. diff --git a/.gsd/milestones/M001/slices/S03/tasks/T02-PLAN.md b/.gsd/milestones/M001/slices/S03/tasks/T02-PLAN.md new file mode 100644 index 0000000..7771978 --- /dev/null +++ b/.gsd/milestones/M001/slices/S03/tasks/T02-PLAN.md @@ -0,0 +1,52 @@ +--- +estimated_steps: 4 +estimated_files: 3 +skills_used: + - react-best-practices + - test +--- + +# T02: Make the follow-up workspace show thread-grounded draft state without autonomous sending + +**Slice:** S03 — Reply and follow-up drafting from real thread context +**Milestone:** M001 + +## Description + +Turn the Follow-up tab into a clearer workspace that shows why the draft was generated now, what thread/package context informed it, and what will happen when the user manually sends/logs it. + +## Steps + +1. Align frontend types with any stronger follow-up draft contract exposed by T01. +2. Refine the Follow-up tab in `JobDetailsDialog.tsx` so it surfaces thread/package grounding, editable draft state, and the manual-send boundary clearly. +3. Add a focused React test that proves generation, editability, and manual send/log behavior for the follow-up loop. +4. Verify the focused follow-up workspace test and make sure it covers the saved-context/thread-aware behavior instead of generic form rendering. + +## Must-Haves + +- [ ] The Follow-up tab shows why the follow-up is due and what job/thread/package context informed the draft. +- [ ] The draft remains editable before sending and the send action stays explicitly manual. +- [ ] The focused React test proves generate/edit/send-log behavior for the follow-up loop. + +## Verification + +- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-followup-drafts.test.tsx` +- Confirm the test proves thread-aware draft context plus manual send/log behavior in `job-tracker-ui/src/components/JobDetailsDialog.tsx`. + +## Observability Impact + +- Signals added/changed: clearer Follow-up tab state around draft reason, informing context, and sent/logged outcome. +- How a future agent inspects this: open the Follow-up tab in `job-tracker-ui/src/components/JobDetailsDialog.tsx` and read `job-tracker-ui/src/job-details-followup-drafts.test.tsx`. +- Failure state exposed: the UI should distinguish draft-generation failure, editable draft state, and sent/logged follow-up state. + +## Inputs + +- `job-tracker-ui/src/components/JobDetailsDialog.tsx` — current follow-up UI. +- `job-tracker-ui/src/types.ts` — current frontend contracts. +- `JobTrackerApi/Controllers/JobApplicationsController.cs` — stronger follow-up draft contract from T01. + +## Expected Output + +- `job-tracker-ui/src/components/JobDetailsDialog.tsx` — follow-up workspace grounded in saved/job/thread context. +- `job-tracker-ui/src/types.ts` — aligned follow-up DTO shape if T01 adds context fields. +- `job-tracker-ui/src/job-details-followup-drafts.test.tsx` — focused frontend proof for the follow-up loop. diff --git a/.gsd/milestones/M001/slices/S03/tasks/T02-SUMMARY.md b/.gsd/milestones/M001/slices/S03/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..0d38f23 --- /dev/null +++ b/.gsd/milestones/M001/slices/S03/tasks/T02-SUMMARY.md @@ -0,0 +1,31 @@ +--- +title: T02 summary +status: done +files: + - job-tracker-ui/src/components/JobDetailsDialog.tsx + - job-tracker-ui/src/types.ts + - job-tracker-ui/src/job-details-followup-drafts.test.tsx +verification: + - CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-followup-drafts.test.tsx + - CI=true npm --prefix job-tracker-ui run build +--- + +Refined the Follow-up tab so it exposes the thread and saved-package grounding behind the draft instead of behaving like a generic email form. + +What changed: +- `job-tracker-ui/src/types.ts` + - added a typed `FollowUpDraft` contract aligned to the richer backend response +- `job-tracker-ui/src/components/JobDetailsDialog.tsx` + - switched the follow-up state to the shared typed DTO + - added a follow-up context panel showing thread subject, last sender, context summary, and context signals + - clarified the manual-send boundary directly in the recipient/body helper text + - kept the draft editable before send while making the send-and-log behavior explicit +- `job-tracker-ui/src/job-details-followup-drafts.test.tsx` + - added focused frontend proof that the Follow-up tab shows thread grounding, keeps sending manual, and posts the edited draft through the send/log endpoint + +Verification: +- Focused follow-up workspace test passed: `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/job-details-followup-drafts.test.tsx` +- Production frontend build passed: `CI=true npm --prefix job-tracker-ui run build` + +Runtime note: +- Browser-based local UAT against this worktree was attempted but blocked by environment issues: port 3000 is served by an external nginx bundle unrelated to this worktree, the CRA dev server crashed under Node 25 in `fork-ts-checker`, and the browser harness did not successfully execute the locally served static bundle. Automated verification for the implemented code paths still passed. diff --git a/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs new file mode 100644 index 0000000..ecf54fb --- /dev/null +++ b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs @@ -0,0 +1,148 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobApplicationsFollowUpDraftTests +{ + [Fact] + public async Task Follow_up_draft_uses_imported_thread_and_saved_package_context() + { + await using var db = CreateDb(); + var company = new Company + { + Name = "Acme", + RecruiterName = "Maria Recruiter", + RecruiterEmail = "maria@acme.test", + OwnerUserId = "user-1" + }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser + { + Id = "user-1", + UserName = "user@example.test", + Email = "user@example.test", + DisplayName = "Casey Candidate" + }); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Status = "Waiting", + DateApplied = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + Description = "Need .NET APIs and clear stakeholder communication.", + ShortSummary = "Strong overlap in backend API delivery.", + TailoredCvText = "Saved tailored CV", + CoverLetterText = "Saved cover letter", + RecruiterMessageDraft = "Saved recruiter message", + Notes = "Original notes\n\n<<>>\nSaved application answer\n<<>>" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Subject = "Backend Developer application update", + ExternalThreadId = "thread-42", + ExternalFrom = "Maria Recruiter ", + ExternalTo = "user@example.test", + Content = "We're aligning interview times and want someone who can own the API layer.", + Date = new DateTime(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc) + }); + await db.SaveChangesAsync(); + + var summarizer = new Mock(); + summarizer + .Setup(service => service.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string instruction, string context, int _, int __) => + { + if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase)) + { + return "Keep the thread moving on interview timing.\nReuse the API-ownership language from the recruiter."; + } + + if (instruction.Contains("follow-up email", StringComparison.OrdinalIgnoreCase)) + { + return context.Contains("Saved cover letter", StringComparison.OrdinalIgnoreCase) + && context.Contains("Backend Developer application update", StringComparison.OrdinalIgnoreCase) + && context.Contains("Maria Recruiter", StringComparison.OrdinalIgnoreCase) + ? "Hi Maria,\n\nI wanted to follow up on the Backend Developer thread and reiterate my fit for owning the API layer.\n\nThanks,\nCasey" + : "Generic follow-up"; + } + + return "Generated text"; + }); + + var controller = CreateController(db, summarizer.Object, "user-1"); + var result = await controller.GetFollowUpDraft(job.Id, "waiting-update", null, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + + Assert.Equal("Re: Backend Developer application update", payload.Subject); + Assert.Contains("Maria", payload.Body); + Assert.Contains("Saved application package material is available", payload.ContextSummary); + Assert.Contains(payload.ContextSignals, item => item.Contains("Saved cover letter available", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(payload.ContextSignals, item => item.Contains("thread participants", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("Backend Developer application update", payload.ThreadSubject); + Assert.Contains("Maria Recruiter", payload.LastCorrespondenceFrom ?? string.Empty); + } + + private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) + { + var controller = new JobApplicationsController(db, summarizer, Mock.Of(), CreateUserManager().Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + }; + return controller; + } + + private static JobTrackerContext CreateDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns("user-1"); + return new JobTrackerContext(options, currentUser.Object); + } + + private static Mock> CreateUserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Options.Create(new IdentityOptions()), + new PasswordHasher(), + Array.Empty>(), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null!, + new NullLogger>() + ); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index eaa5987..a274f9f 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -121,6 +121,9 @@ namespace JobTrackerApi.Controllers private sealed record AttachmentContextResult(string Context, List Signals, List UsedFiles); private sealed record CorrespondenceContextResult(string Context, List Signals, List Participants, List ThreadIds); + private const string ApplicationAnswerDraftStart = "<<>>"; + private const string ApplicationAnswerDraftEnd = "<<>>"; + private async Task BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null) { HashSet? allowedIds = null; @@ -280,6 +283,73 @@ namespace JobTrackerApi.Controllers return new CorrespondenceContextResult(context.ToString().Trim(), signals, participants, threadIds); } + private static string? ExtractSavedApplicationAnswerDraft(string? notes) + { + var value = (notes ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value)) return null; + + var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal); + var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal); + if (startIndex >= 0 && endIndex > startIndex) + { + var between = value[(startIndex + ApplicationAnswerDraftStart.Length)..endIndex].Trim(); + return string.IsNullOrWhiteSpace(between) ? null : between; + } + + const string legacyPrefix = "Application answer draft:"; + var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase); + if (legacyIndex >= 0) + { + var legacy = value[(legacyIndex + legacyPrefix.Length)..].Trim(); + return string.IsNullOrWhiteSpace(legacy) ? null : legacy; + } + + return null; + } + + private static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage) + { + var subject = (lastMessage?.Subject ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(subject)) + { + return subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) + ? subject + : $"Re: {subject}"; + } + + return $"Following up on {job.JobTitle} application"; + } + + private static List BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer) + { + var signals = new List(); + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}"); + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}"); + if (lastMessage is not null) + { + signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}"); + } + if (correspondenceContext?.Participants.Count > 0) + { + signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}"); + } + if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available"); + if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available"); + if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available"); + if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available"); + + if (correspondenceContext is not null) + { + foreach (var signal in correspondenceContext.Signals) + { + if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal); + } + } + + return signals.Take(6).ToList(); + } + private static bool IsExtractableAttachmentExtension(string? extension) { return extension?.Trim().ToLowerInvariant() switch @@ -1547,7 +1617,7 @@ namespace JobTrackerApi.Controllers ); public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason); public sealed record DuplicateCheckResult(bool HasDuplicates, List Matches); - public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn); + public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt); public sealed record FocusPlanDto( List ImmediatePriorities, List CvBulletIdeas, @@ -2260,12 +2330,16 @@ Candidate master CV: var currentUser = await GetCurrentUserAsync(cancellationToken); var signerName = GetPreferredDisplayName(currentUser); var greeting = BuildGreeting(job); - var subject = $"Following up on {job.JobTitle} application"; + var subject = BuildFollowUpSubject(job, lastMessage); var reference = lastMessage?.Subject ?? job.JobTitle; var summary = job.ShortSummary; var appliedDate = job.DateApplied.ToString("MMMM d, yyyy"); var tagHighlights = SplitTags(job.Tags).Take(4).ToList(); var companyName = job.Company?.Name ?? "your team"; + var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); + var correspondenceContext = await BuildCorrespondenceContextAsync(id, cancellationToken); + var savedPackageMaterial = new SavedPackageMaterial(job.TailoredCvText, job.CoverLetterText, job.RecruiterMessageDraft, job.Notes); + var savedApplicationAnswer = ExtractSavedApplicationAnswerDraft(job.Notes); var requestedMode = string.IsNullOrWhiteSpace(mode) ? (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) ? "post-interview" @@ -2274,7 +2348,17 @@ Candidate master CV: : job.Status == "Rejected" ? "feedback-request" : "post-apply") : mode.Trim().ToLowerInvariant(); - var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); + + var followUpContextSignals = BuildFollowUpContextSignals(job, lastMessage, correspondenceContext, savedPackageMaterial, savedApplicationAnswer); + var contextSummary = string.Join(" ", new[] + { + reason.Trim(), + lastMessage is not null ? $"Latest thread activity was on {lastMessage.Date:MMMM d, yyyy}." : "No imported thread activity exists yet.", + !string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft) || !string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText) + ? "Saved application package material is available for reuse." + : "No saved application package material is available yet." + }.Where(x => !string.IsNullOrWhiteSpace(x))); + var aiContext = $@"Candidate name: {signerName} Role: {job.JobTitle} Company: {companyName} @@ -2282,18 +2366,33 @@ Applied on: {appliedDate} Current status: {job.Status} Requested follow-up mode: {requestedMode} Reason for follow-up: {reason} +Follow-up context summary: {contextSummary} Last message subject: {lastMessage?.Subject ?? "None"} Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d, yyyy") : "None")} +Last message from: {lastMessage?.ExternalFrom ?? lastMessage?.From ?? "None"} Relevant skills/tags: {(tagHighlights.Count > 0 ? string.Join(", ", tagHighlights) : "None provided")} Short fit summary: {summary ?? "None provided"} + +Imported correspondence context: +{correspondenceContext?.Context ?? "No imported correspondence context available."} + +Saved application package material: +Tailored CV: {savedPackageMaterial.TailoredCvText ?? "None saved"} +Cover letter: {savedPackageMaterial.CoverLetterText ?? "None saved"} +Recruiter message: {savedPackageMaterial.RecruiterMessageDraft ?? "None saved"} +Application answer: {savedApplicationAnswer ?? "None saved"} + +Follow-up context signals: +{(followUpContextSignals.Count > 0 ? string.Join("\n", followUpContextSignals.Select(signal => $"- {signal}")) : "- No extra context signals available.")} + Job description: {job.TranslatedDescription ?? job.Description ?? "No job description available."}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}"; var aiBody = await _summarizer.SummarizeSectionAsync( - $"Write a concise, professional follow-up email in first person for the mode '{requestedMode}'. Mention that the candidate applied on the provided date, reference the exact role and company, mention one or two concrete details from the role or fit summary, and close with polite interest in next steps. Adjust the tone to the stage: post-apply should be light and interested, waiting-update should ask about progress, post-interview should thank them and reaffirm fit, offer-checkin should be warm and practical, feedback-request should be respectful and brief. Keep it specific, warm, and under 140 words. Return only the email body.", + $"Write a concise, professional follow-up email in first person for the mode '{requestedMode}'. Mention that the candidate applied on the provided date, reference the exact role and company, use the imported correspondence and saved application package material when they sharpen specificity, and keep the manual-send boundary intact by returning draft text only. Adjust the tone to the stage: post-apply should be light and interested, waiting-update should ask about progress, post-interview should thank them and reaffirm fit, offer-checkin should be warm and practical, feedback-request should be respectful and brief. Keep it specific, warm, and under 140 words. Return only the email body.", aiContext, - 190, - 70); + 210, + 80); var fallbackIntro = requestedMode switch { @@ -2304,16 +2403,24 @@ Job description: _ => $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.", }; + var strongestOverlap = !string.IsNullOrWhiteSpace(savedApplicationAnswer) + ? savedApplicationAnswer!.Split(new[] { '.', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() + : null; + var fallbackBody = string.Join("\n\n", new[] { greeting, fallbackIntro, !string.IsNullOrWhiteSpace(summary) - ? $"From the posting and my background, the strongest overlap seems to be {summary.Trim().TrimEnd('.')}." - : tagHighlights.Count > 0 - ? $"The role's focus on {string.Join(", ", tagHighlights.Take(2))} especially stood out to me, and it lines up well with my experience." - : null, - $"I would be glad to share any additional details that would be helpful as you move through next steps for {reference}.", + ? $"The strongest overlap still looks like {summary.Trim().TrimEnd('.')}." + : !string.IsNullOrWhiteSpace(strongestOverlap) + ? strongestOverlap + : tagHighlights.Count > 0 + ? $"The role's focus on {string.Join(", ", tagHighlights.Take(2))} especially stood out to me, and it lines up well with my experience." + : null, + lastMessage is not null && !string.IsNullOrWhiteSpace(lastMessage.Subject) + ? $"I also wanted to keep the thread moving on {lastMessage.Subject.Trim()} if there is anything else you need from me." + : $"I would be glad to share any additional details that would be helpful as you move through next steps for {reference}.", $"Thanks for your time,\n{signerName}" }.Where(x => !string.IsNullOrWhiteSpace(x))); @@ -2323,7 +2430,16 @@ Job description: body = string.Join("\n\n", new[] { greeting, body, $"Thanks,\n{signerName}" }.Where(x => !string.IsNullOrWhiteSpace(x))); } - return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today)); + return Ok(new FollowUpDraftDto( + subject, + body, + reason, + DateTime.Today, + contextSummary, + followUpContextSignals, + lastMessage?.Subject, + lastMessage?.ExternalFrom ?? lastMessage?.From, + lastMessage?.Date)); } [HttpPost("{id:int}/send-followup")] diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index bfefe54..75d7df8 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -19,7 +19,7 @@ import { } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; -import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types"; +import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; @@ -38,16 +38,8 @@ type AttachmentItem = { useForAi: boolean; }; -type FollowUpDraft = { - subject: string; - body: string; - reason: string; - suggestedSendOn: string; -}; - type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview"; type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold"; -type PackageDraftKind = "tailoredCv" | "coverLetter" | "applicationAnswer" | "recruiterMessage"; type PackageWorkspaceState = { coverLetter: string; @@ -612,9 +604,20 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {loadingDraft ? : followUpDraft ? ( - - {t("jobDetailsReason")}{followUpDraft.reason} - {t("jobDetailsSuggestedSendDate")}{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()} + + + Follow-up context + + {followUpDraft.threadSubject ? : null} + {followUpDraft.lastCorrespondenceAt ? : null} + + + {followUpDraft.contextSummary} + + Why now{followUpDraft.reason} + Last sender{followUpDraft.lastCorrespondenceFrom ?? "No imported sender yet"} + + {followUpDraft.contextSignals?.length ? : null} @@ -629,9 +632,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, - setDraftRecipient(e.target.value)} helperText={t("jobDetailsRecipientHelp")} /> + setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} Manual send only — nothing is dispatched until you press send.`} /> setDraftSubject(e.target.value)} /> - setDraftBody(e.target.value)} /> + setDraftBody(e.target.value)} helperText="You can edit this before sending. Sending stays manual and logs the sent note back to correspondence." />