feat(workspace): add application intelligence
Phase 5.3. Three read-only reads that answer "how suitable is this job", "how
does my experience match", "what am I missing", "what happened previously".
Timeline (GET /{id}/timeline) is an interpretation layer over JobEvent, which
stays the source of historical truth. Each row gains a readable summary, a
category and a milestone flag; events group by day. Milestones are returned
unfiltered, because narrowing the detail must not hide what actually happened.
Job analysis (GET /{id}/analysis) extracts role, company, location, employment
type, seniority, salary, technologies, skills, responsibilities and keywords
from the advert, reusing the existing SkillTagger so the vocabulary matches the
job importer. It also reports what the advert does NOT say, which is usually the
more useful half.
Career matching (GET /{id}/match) feeds the master CareerProfile into the same
JobCvMatchService the CV builder uses, so one application scores identically
whichever surface asks. It returns the score, matched and missing skills, and
which experience and project entries are the evidence for each match.
All three are deterministic and own no data — no new table, no new column, and
nothing writes to the CareerProfile, a CvVariant, or the JobApplication. The AI
narrative stays where it already was, in AiWorkspaceService's job-analysis and
career-match modules, generated only when the user asks and versioned by the
append-only AiInteraction history. Opening a section costs nothing and changes
nothing.
Frontend adds Timeline, Analysis and Match sections to the workspace, sharing
one loader so loading, empty and error states are consistent. The deterministic
answer renders first, with the AI panel below it.
345 backend tests, 104 frontend tests, type check, production build all pass
locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Phase 5.3 — Application Intelligence. These tests pin the two properties that matter most: the
|
||||
// results are OWNERSHIP-SCOPED, and nothing here writes to the user's data. Career matching in
|
||||
// particular reads the master CareerProfile and must leave it byte-identical.
|
||||
public sealed class ApplicationIntelligenceTests
|
||||
{
|
||||
private const string Advert = """
|
||||
We are hiring a Senior Backend Developer (full-time) to join our platform team in Oslo.
|
||||
|
||||
You will:
|
||||
- Build and operate REST APIs used by every product surface
|
||||
- Own services end to end, from design through production support
|
||||
- Work closely with product and design on new features
|
||||
|
||||
We expect:
|
||||
- Strong experience with C# and .NET
|
||||
- Solid SQL and Docker knowledge
|
||||
- Experience with unit tests and CI/CD
|
||||
""";
|
||||
|
||||
private static (JobTrackerContext db, ApplicationIntelligenceService intelligence, ApplicationTimelineService timeline) New(string userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
return (db, new ApplicationIntelligenceService(db, new JobCvMatchService()), new ApplicationTimelineService(db));
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner, Action<JobApplication>? tweak = null)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
CompanyId = company.Id,
|
||||
JobTitle = "Senior Backend Developer",
|
||||
Status = "Applied",
|
||||
Location = "Oslo",
|
||||
Description = Advert,
|
||||
};
|
||||
tweak?.Invoke(job);
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static async Task<CareerProfile> SeedProfileAsync(JobTrackerContext db, string owner)
|
||||
{
|
||||
var profile = new CareerProfile
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
Experiences =
|
||||
{
|
||||
new CareerExperience
|
||||
{
|
||||
OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true,
|
||||
BulletsJson = """["Built REST APIs in C# and .NET","Ran the SQL migration programme"]""",
|
||||
},
|
||||
new CareerExperience
|
||||
{
|
||||
OwnerUserId = owner, Title = "Barista", Company = "Coffee Co", Start = "2018", End = "2020",
|
||||
BulletsJson = """["Served customers"]""",
|
||||
},
|
||||
},
|
||||
Projects =
|
||||
{
|
||||
new CareerProject
|
||||
{
|
||||
OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author",
|
||||
BulletsJson = """["Docker based CI/CD for six services"]""",
|
||||
},
|
||||
},
|
||||
Skills = { new CareerSkill { OwnerUserId = owner, Name = "C#", Category = "Languages" } },
|
||||
};
|
||||
db.CareerProfiles.Add(profile);
|
||||
await db.SaveChangesAsync();
|
||||
return profile;
|
||||
}
|
||||
|
||||
// ---------- Milestone 1: timeline ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_turns_raw_events_into_readable_summaries()
|
||||
{
|
||||
var (db, _, timeline) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-5) });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", OldValue = "Applied", NewValue = "Interview", At = DateTime.Now.AddDays(-1) });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await timeline.GetAsync("user-1", job.Id, null, false, default);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList();
|
||||
Assert.Contains("Moved from Applied to Interview", summaries);
|
||||
Assert.Contains("Application created", summaries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_groups_by_day_newest_first()
|
||||
{
|
||||
var (db, _, timeline) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-3) });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-3).AddHours(2) });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", At = DateTime.Now });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await timeline.GetAsync("user-1", job.Id, null, false, default);
|
||||
|
||||
Assert.Equal(2, result!.Days.Count);
|
||||
Assert.True(result.Days[0].Date > result.Days[1].Date);
|
||||
Assert.Equal(2, result.Days[1].Events.Count);
|
||||
Assert.Equal("Today", result.Days[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_detects_milestones_and_ignores_routine_events()
|
||||
{
|
||||
var (db, _, timeline) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Interview", At = DateTime.Now });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Waiting", At = DateTime.Now.AddDays(-1) });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-2) });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await timeline.GetAsync("user-1", job.Id, null, false, default);
|
||||
|
||||
Assert.Single(result!.Milestones);
|
||||
Assert.Equal("Moved to Interview", result.Milestones[0].Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_filters_by_category_but_keeps_the_milestone_spine()
|
||||
{
|
||||
var (db, _, timeline) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Offer", At = DateTime.Now });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await timeline.GetAsync("user-1", job.Id, "ai", false, default);
|
||||
|
||||
Assert.Single(result!.Days.SelectMany(d => d.Events));
|
||||
Assert.Equal("ai", result.Days[0].Events[0].Category);
|
||||
// Filtering the detail must not hide what actually happened.
|
||||
Assert.Single(result.Milestones);
|
||||
Assert.Equal(2, result.TotalEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_is_not_readable_for_another_users_application()
|
||||
{
|
||||
var (db, _, timeline) = New("user-1");
|
||||
await using var _d = db;
|
||||
var other = await SeedJobAsync(db, "user-2");
|
||||
|
||||
Assert.Null(await timeline.GetAsync("user-1", other.Id, null, false, default));
|
||||
}
|
||||
|
||||
// ---------- Milestone 2: job analysis ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Analysis_extracts_structure_from_the_advert()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var a = await intelligence.AnalyzeAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.NotNull(a);
|
||||
Assert.Equal("Senior Backend Developer", a!.Role);
|
||||
Assert.Equal("Acme", a.Company);
|
||||
Assert.Equal("Oslo", a.Location);
|
||||
Assert.Equal("Full-time", a.EmploymentType);
|
||||
Assert.Equal("Senior", a.Seniority);
|
||||
Assert.Contains("C#", a.Technologies);
|
||||
Assert.Contains(".NET", a.Technologies);
|
||||
Assert.Contains("Docker", a.Technologies);
|
||||
Assert.NotEmpty(a.ImportantRequirements);
|
||||
Assert.NotEmpty(a.Responsibilities);
|
||||
Assert.True(a.HasJobDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analysis_reports_what_the_advert_does_not_say()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1", j => { j.Salary = null; j.JobUrl = null; });
|
||||
|
||||
var a = await intelligence.AnalyzeAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Contains(a!.MissingInformation, m => m.Contains("Salary", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(a.MissingInformation, m => m.Contains("link", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analysis_degrades_gracefully_without_an_advert()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1", j => j.Description = null);
|
||||
|
||||
var a = await intelligence.AnalyzeAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.False(a!.HasJobDescription);
|
||||
Assert.Empty(a.Responsibilities);
|
||||
Assert.Contains("advert", a.Summary, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analysis_is_deterministic()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var first = await intelligence.AnalyzeAsync("user-1", job.Id, default);
|
||||
var second = await intelligence.AnalyzeAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal(first!.Keywords, second!.Keywords);
|
||||
Assert.Equal(first.Summary, second.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analysis_is_not_readable_for_another_users_application()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var other = await SeedJobAsync(db, "user-2");
|
||||
|
||||
Assert.Null(await intelligence.AnalyzeAsync("user-1", other.Id, default));
|
||||
}
|
||||
|
||||
// ---------- Milestone 3: career matching ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Match_scores_the_profile_against_the_advert_with_evidence()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await SeedProfileAsync(db, "user-1");
|
||||
|
||||
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.NotNull(m);
|
||||
Assert.True(m!.HasCareerProfile);
|
||||
Assert.True(m.Score > 0);
|
||||
Assert.Contains("C#", m.MatchedSkills);
|
||||
// The relevant-experience list is evidence drawn FROM the profile, not a copy of it.
|
||||
Assert.Contains(m.RelevantExperience, e => e.Title == "Backend Developer");
|
||||
Assert.DoesNotContain(m.RelevantExperience, e => e.Title == "Barista");
|
||||
Assert.NotEmpty(m.Suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_never_writes_to_the_career_profile()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var profile = await SeedProfileAsync(db, "user-1");
|
||||
var bulletsBefore = profile.Experiences[0].BulletsJson;
|
||||
var experienceCountBefore = profile.Experiences.Count;
|
||||
var versionBefore = profile.Version;
|
||||
|
||||
await intelligence.MatchAsync("user-1", job.Id, default);
|
||||
|
||||
var after = await db.CareerProfiles.Include(p => p.Experiences).FirstAsync(p => p.OwnerUserId == "user-1");
|
||||
Assert.Equal(experienceCountBefore, after.Experiences.Count);
|
||||
Assert.Equal(bulletsBefore, after.Experiences.First(e => e.Title == "Backend Developer").BulletsJson);
|
||||
Assert.Equal(versionBefore, after.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_asks_for_a_profile_before_scoring_anything()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.False(m!.HasCareerProfile);
|
||||
Assert.Equal(0, m.Score);
|
||||
Assert.Contains(m.Suggestions, s => s.Contains("career profile", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_only_reads_the_requesting_users_profile()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored
|
||||
|
||||
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.False(m!.HasCareerProfile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_is_not_readable_for_another_users_application()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var other = await SeedJobAsync(db, "user-2");
|
||||
|
||||
Assert.Null(await intelligence.MatchAsync("user-1", other.Id, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_counts_ai_suggestions_without_generating_any()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await SeedProfileAsync(db, "user-1");
|
||||
db.AiInteractions.Add(new AiInteraction
|
||||
{
|
||||
OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "career-match",
|
||||
Title = "Career match", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var interactionsBefore = await db.AiInteractions.CountAsync();
|
||||
|
||||
var m = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal(1, m!.AiSuggestionCount);
|
||||
// Reading the match must not itself call the AI or append history.
|
||||
Assert.Equal(interactionsBefore, await db.AiInteractions.CountAsync());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user