fix(analysis): refresh stale job summaries
This commit is contained in:
@@ -292,11 +292,79 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
Assert.Equal("NO", opportunity.CountryCode);
|
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<ISummarizerService>();
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
|
summarizer.Setup(service => service.SummarizeAsync(It.IsAny<string>(), 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<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal("Fresh analysis", (await db.JobApplications.SingleAsync()).ShortSummary);
|
||||||
|
summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 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<NoContentResult>(await controller.Update(job.Id, request, CancellationToken.None));
|
||||||
|
summarizer.Verify(service => service.SummarizeAsync(It.IsAny<string>(), 160, 60), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobApplicationsController CreateController(
|
||||||
|
JobTrackerContext db,
|
||||||
|
string userId,
|
||||||
|
Mock<ISummarizerService>? summarizerOverride = null,
|
||||||
|
ApplicationUser? lookupUser = null,
|
||||||
|
IReadOnlyList<string>? roles = null)
|
||||||
|
{
|
||||||
|
var summarizer = summarizerOverride ?? new Mock<ISummarizerService>();
|
||||||
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text");
|
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).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<string>());
|
||||||
|
|
||||||
var controller = new JobApplicationsController(db, summarizer.Object, users.Object);
|
var controller = new JobApplicationsController(db, summarizer.Object, users.Object);
|
||||||
controller.ControllerContext = new ControllerContext
|
controller.ControllerContext = new ControllerContext
|
||||||
@@ -312,11 +380,6 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> CreateUserManager()
|
|
||||||
{
|
|
||||||
return TestHostFactory.CreateUserManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JobTrackerContext CreateDb()
|
private static JobTrackerContext CreateDb()
|
||||||
{
|
{
|
||||||
return TestHostFactory.CreateInMemoryDb();
|
return TestHostFactory.CreateInMemoryDb();
|
||||||
|
|||||||
@@ -833,6 +833,9 @@ Canonical profile:
|
|||||||
var oldStatus = job.Status;
|
var oldStatus = job.Status;
|
||||||
var oldResponseReceived = job.ResponseReceived;
|
var oldResponseReceived = job.ResponseReceived;
|
||||||
var oldResponseDate = job.ResponseDate;
|
var oldResponseDate = job.ResponseDate;
|
||||||
|
var previousJobTitle = job.JobTitle;
|
||||||
|
var previousDescription = job.Description;
|
||||||
|
var previousTranslatedDescription = job.TranslatedDescription;
|
||||||
|
|
||||||
var title = (request.JobTitle ?? "").Trim();
|
var title = (request.JobTitle ?? "").Trim();
|
||||||
if (title.Length == 0) return BadRequest("Job title is required.");
|
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.
|
// Status may have changed above; keep DateApplied consistent with the stage.
|
||||||
SyncAppliedDateWithHistory(job);
|
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)
|
if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate)
|
||||||
{
|
{
|
||||||
_db.JobEvents.Add(new JobEvent
|
_db.JobEvents.Add(new JobEvent
|
||||||
@@ -887,6 +920,43 @@ Canonical profile:
|
|||||||
return NoContent();
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
|
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
|
||||||
[HttpGet("pipeline")]
|
[HttpGet("pipeline")]
|
||||||
public ActionResult<IEnumerable<PipelineStageDto>> GetPipeline()
|
public ActionResult<IEnumerable<PipelineStageDto>> GetPipeline()
|
||||||
|
|||||||
Reference in New Issue
Block a user