diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index b7b1dae..5068421 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -292,11 +292,79 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Equal("NO", opportunity.CountryCode); } - private static JobApplicationsController CreateController(JobTrackerContext db, string userId) + [Fact] + public async Task Update_refreshes_analysis_only_when_the_advert_materially_changes() { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var originalDescription = "Build and operate C# services with SQL, Docker and Azure. Work with product teams to deliver reliable APIs."; + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Description = originalDescription, + ShortSummary = "Old analysis", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + var summarizer = new Mock(); + summarizer.Setup(service => service.SummarizeAsync(It.IsAny(), 160, 60)).ReturnsAsync("Fresh analysis"); + var user = new ApplicationUser { Id = "user-1", UserName = "user", AiEnabled = true }; + var controller = CreateController(db, "user-1", summarizer, user, new[] { "Premium" }); + var request = new UpdateJobApplicationRequest( + JobTitle: "Backend Developer", + CompanyId: company.Id, + Status: "Applied", + ResponseReceived: false, + ResponseDate: null, + Location: null, + Salary: null, + SalaryMin: null, + SalaryMax: null, + SalaryCurrency: null, + SalaryPeriod: null, + NextAction: null, + FollowUpAt: null, + Notes: null, + Description: "Lead a Kubernetes platform team building Go services for machine-learning workloads and distributed event streaming.", + TranslatedDescription: null, + DescriptionLanguage: "en", + Tags: null, + Deadline: null, + CoverLetterText: null, + JobUrl: null, + DateApplied: null, + FeedbackRequestedAt: null, + StatusChangedAt: null); + + Assert.IsType(await controller.Update(job.Id, request, CancellationToken.None)); + + Assert.Equal("Fresh analysis", (await db.JobApplications.SingleAsync()).ShortSummary); + summarizer.Verify(service => service.SummarizeAsync(It.IsAny(), 160, 60), Times.Once); + Assert.Contains(await db.JobEvents.ToListAsync(), item => item.Type == "AiRefreshed" && item.Note!.Contains("automatically")); + + request = request with { Description = request.Description + " " }; + Assert.IsType(await controller.Update(job.Id, request, CancellationToken.None)); + summarizer.Verify(service => service.SummarizeAsync(It.IsAny(), 160, 60), Times.Once); + } + + private static JobApplicationsController CreateController( + JobTrackerContext db, + string userId, + Mock? summarizerOverride = null, + ApplicationUser? lookupUser = null, + IReadOnlyList? roles = null) + { + var summarizer = summarizerOverride ?? new Mock(); summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("generated text"); - var users = CreateUserManager(); + var users = TestHostFactory.CreateUserManager(lookupUser); + if (lookupUser is not null) + users.Setup(manager => manager.GetRolesAsync(lookupUser)).ReturnsAsync(roles?.ToList() ?? new List()); var controller = new JobApplicationsController(db, summarizer.Object, users.Object); controller.ControllerContext = new ControllerContext @@ -312,11 +380,6 @@ public sealed class JobApplicationsEndpointBehaviorTests return controller; } - private static Mock> CreateUserManager() - { - return TestHostFactory.CreateUserManager(); - } - private static JobTrackerContext CreateDb() { return TestHostFactory.CreateInMemoryDb(); diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 29774b4..166e748 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -833,6 +833,9 @@ Canonical profile: var oldStatus = job.Status; var oldResponseReceived = job.ResponseReceived; var oldResponseDate = job.ResponseDate; + var previousJobTitle = job.JobTitle; + var previousDescription = job.Description; + var previousTranslatedDescription = job.TranslatedDescription; var title = (request.JobTitle ?? "").Trim(); if (title.Length == 0) return BadRequest("Job title is required."); @@ -868,6 +871,36 @@ Canonical profile: // Status may have changed above; keep DateApplied consistent with the stage. SyncAppliedDateWithHistory(job); + // The persisted short analysis is generated on create. Refresh it only when the source + // job content materially changes; opening the workspace or saving an unrelated field + // must never spend another AI call. Clear the old summary first so a failed refresh + // cannot present analysis for an advert the user has replaced. + if (HasSubstantialAnalysisChange( + previousJobTitle, previousDescription, previousTranslatedDescription, + job.JobTitle, job.Description, job.TranslatedDescription)) + { + job.ShortSummary = null; + try + { + if (await CanCurrentUserUseAiAsync(cancellationToken)) + { + job.ShortSummary = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60); + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "AiRefreshed", + Note = "Job analysis automatically refreshed after the advert changed.", + At = DateTime.Now, + }); + } + } + catch + { + // Editing the application must still succeed if the optional analysis provider + // is unavailable. The manual refresh action remains available. + } + } + if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate) { _db.JobEvents.Add(new JobEvent @@ -887,6 +920,43 @@ Canonical profile: return NoContent(); } + private static bool HasSubstantialAnalysisChange( + string? previousTitle, + string? previousDescription, + string? previousTranslatedDescription, + string? currentTitle, + string? currentDescription, + string? currentTranslatedDescription) + { + if (!string.Equals(NormalizeAnalysisText(previousTitle), NormalizeAnalysisText(currentTitle), StringComparison.Ordinal)) + return true; + + var before = NormalizeAnalysisText($"{previousDescription} {previousTranslatedDescription}"); + var after = NormalizeAnalysisText($"{currentDescription} {currentTranslatedDescription}"); + if (before == after) return false; + if (before.Length == 0 || after.Length == 0) return true; + + var lengthChange = Math.Abs(before.Length - after.Length); + if (lengthChange >= Math.Max(80, before.Length / 10)) return true; + + var beforeTokens = before.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(StringComparer.Ordinal); + var afterTokens = after.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(StringComparer.Ordinal); + var union = beforeTokens.Union(afterTokens).Count(); + if (union == 0) return false; + var overlap = beforeTokens.Intersect(afterTokens).Count(); + return 1d - overlap / (double)union >= 0.12d; + } + + private static string NormalizeAnalysisText(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + var words = value.ToLowerInvariant() + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Select(word => word.Trim().Trim(',', '.', ':', ';', '!', '?', '-', '–', '—', '(', ')', '[', ']', '"', '\'')) + .Where(word => word.Length > 0); + return string.Join(' ', words); + } + /// Canonical ordered pipeline stages so the UI renders one source of truth. [HttpGet("pipeline")] public ActionResult> GetPipeline()