diff --git a/JobTrackerApi.Tests/AiWorkspaceTests.cs b/JobTrackerApi.Tests/AiWorkspaceTests.cs index d4b85f3..52a3e6d 100644 --- a/JobTrackerApi.Tests/AiWorkspaceTests.cs +++ b/JobTrackerApi.Tests/AiWorkspaceTests.cs @@ -14,10 +14,13 @@ public sealed class AiWorkspaceTests public string? Next = "## Result\nGenerated suggestion."; public int Calls; public string? LastInstruction; + // The source text the module assembled — what the prompt actually saw. + public string? LastText; public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) { Calls++; LastInstruction = instruction; + LastText = text; return Task.FromResult(Next); } public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); diff --git a/JobTrackerApi.Tests/InterviewAiContextTests.cs b/JobTrackerApi.Tests/InterviewAiContextTests.cs new file mode 100644 index 0000000..1898032 --- /dev/null +++ b/JobTrackerApi.Tests/InterviewAiContextTests.cs @@ -0,0 +1,226 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Interview AI context integration. The interview module now sees what the workspace already computed +// — requirements, matched skills, gaps, relevant experience — so its questions are about THIS +// application rather than generic. The context is deterministic and read-only, so this adds no second +// AI pipeline and cannot change the user's data. +public sealed class InterviewAiContextTests +{ + private sealed class FakeAi : ISummarizerService + { + public string? Next = "## Likely questions\nSomething specific."; + public string? LastText; + public int Calls; + + public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) + { + Calls++; + LastText = text; + return Task.FromResult(Next); + } + + public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); + public Task ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); + } + + private const string Advert = """ + Senior Backend Developer, full-time, Oslo. + + We expect: + - Strong experience with C# and .NET + - Solid SQL knowledge + - Experience with Kubernetes in production + """; + + private static (JobTrackerContext db, AiWorkspaceService svc, FakeAi ai) New(string userId, bool withIntelligence = true) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + var ai = new FakeAi(); + var intelligence = withIntelligence ? new ApplicationIntelligenceService(db, new JobCvMatchService()) : null; + return (db, new AiWorkspaceService(db, ai, intelligence), ai); + } + + private static async Task SeedJobAsync(JobTrackerContext db, string owner) + { + 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 = "Interview", + Description = Advert, + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static async Task SeedProfileAsync(JobTrackerContext db, string owner) + { + db.CareerProfiles.Add(new CareerProfile + { + OwnerUserId = owner, + Experiences = + { + new CareerExperience + { + OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true, + BulletsJson = """["Built services in C# and .NET","Owned the SQL migration programme"]""", + }, + }, + Projects = + { + new CareerProject { OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author", BulletsJson = """["C# tooling"]""" }, + }, + }); + await db.SaveChangesAsync(); + } + + private static AiGenerateRequest Interview() => new("interview", null, null); + + [Fact] + public async Task Interview_generation_includes_the_job_analysis_context() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.Contains("APPLICATION INTELLIGENCE", ai.LastText); + Assert.Contains("Seniority: Senior", ai.LastText); + Assert.Contains("Key requirements", ai.LastText); + Assert.Contains("Technologies in the advert", ai.LastText); + } + + [Fact] + public async Task Interview_generation_includes_the_career_match_context_and_the_gaps() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.Contains("Match score:", ai.LastText); + Assert.Contains("Skills the candidate demonstrably has", ai.LastText); + // The gaps are the whole point — an interviewer probes what is missing. + Assert.Contains("Gaps the candidate must be ready to address", ai.LastText); + Assert.Contains("Kubernetes", ai.LastText); + Assert.Contains("Most relevant experience", ai.LastText); + Assert.Contains("Backend Developer", ai.LastText); + } + + [Fact] + public async Task Other_modules_are_unchanged() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + + foreach (var module in new[] { "job-analysis", "career-match", "cover-letter", "application-review" }) + { + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", new AiGenerateRequest(module, null, null), "test", default); + Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText); + } + } + + [Fact] + public async Task Generation_still_works_without_the_intelligence_service() + { + // The dependency is optional, so an older construction path degrades to the previous prompt + // rather than failing. + var (db, svc, ai) = New("user-1", withIntelligence: false); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + + var interaction = await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.NotNull(interaction); + Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText); + Assert.Contains("JOB ADVERT", ai.LastText); + } + + [Fact] + public async Task Context_is_scoped_to_the_requesting_user() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-2"); // another user's profile must never be scored in + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + Assert.DoesNotContain("Initech", ai.LastText); + Assert.DoesNotContain("Match score:", ai.LastText); + } + + [Fact] + public async Task Another_users_application_cannot_be_generated_for() + { + var (db, svc, ai) = New("user-1"); + await using var _ = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await svc.GenerateAsync("user-1", other.Id, "profile text", "Ada", Interview(), "test", default)); + Assert.Equal(0, ai.Calls); + } + + [Fact] + public async Task Generation_writes_history_only_and_never_the_users_content() + { + var (db, svc, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + var profileBefore = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); + var descriptionBefore = job.Description; + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + // The only new row is the AiInteraction. No prep item is created — the user must accept one. + Assert.Equal(1, await db.AiInteractions.CountAsync()); + Assert.Equal(0, await db.InterviewPrepItems.CountAsync()); + + var profileAfter = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); + Assert.Equal(profileBefore.Version, profileAfter.Version); + Assert.Equal(profileBefore.Experiences[0].BulletsJson, profileAfter.Experiences[0].BulletsJson); + Assert.Equal(descriptionBefore, (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).Description); + } + + [Fact] + public async Task Existing_prep_items_are_never_overwritten_by_generation() + { + var (db, svc, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + var prep = new InterviewPrepService(db); + var mine = await prep.AddAsync("user-1", job.Id, + new InterviewPrepInput(InterviewPrepCategories.Star, "My STAR example", "My own words", null, null), default); + + await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default); + + var after = await prep.GetAsync("user-1", job.Id, default); + Assert.Equal(1, after!.Total); + Assert.Equal("My own words", after.Groups[0].Items[0].Content); + Assert.Equal(mine!.Id, after.Groups[0].Items[0].Id); + } +} diff --git a/JobTrackerApi/Services/AiWorkspaceService.cs b/JobTrackerApi/Services/AiWorkspaceService.cs index 7d79d7e..8c0e2a3 100644 --- a/JobTrackerApi/Services/AiWorkspaceService.cs +++ b/JobTrackerApi/Services/AiWorkspaceService.cs @@ -46,11 +46,59 @@ public sealed class AiWorkspaceService : IAiWorkspaceService private readonly JobTrackerContext _db; private readonly ISummarizerService _ai; + private readonly IApplicationIntelligenceService? _intelligence; - public AiWorkspaceService(JobTrackerContext db, ISummarizerService ai) + // Optional on purpose: every existing construction of this service keeps working unchanged, and a + // missing intelligence service degrades to the previous prompt rather than failing generation. + public AiWorkspaceService(JobTrackerContext db, ISummarizerService ai, IApplicationIntelligenceService? intelligence = null) { _db = db; _ai = ai; + _intelligence = intelligence; + } + + // The deterministic workspace output, formatted for the prompt. Read-only: AnalyzeAsync and + // MatchAsync own no data and write nothing, so this cannot touch the profile or the application. + private async Task BuildIntelligenceContextAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var analysis = await _intelligence!.AnalyzeAsync(ownerUserId, jobApplicationId, ct); + var match = await _intelligence.MatchAsync(ownerUserId, jobApplicationId, ct); + if (analysis is null && match is null) return string.Empty; + + var sb = new System.Text.StringBuilder(); + sb.Append("\n\nAPPLICATION INTELLIGENCE (already computed — use it, do not restate it):"); + + if (analysis is not null) + { + Line(sb, "Seniority", analysis.Seniority); + Line(sb, "Employment type", analysis.EmploymentType); + List(sb, "Key requirements", analysis.ImportantRequirements); + List(sb, "Technologies in the advert", analysis.Technologies); + } + + if (match is { HasCareerProfile: true }) + { + sb.Append($"\nMatch score: {match.Score}% ({match.Band})"); + List(sb, "Skills the candidate demonstrably has", match.MatchedSkills); + // The gaps are the point: this is where an interviewer will probe. + List(sb, "Gaps the candidate must be ready to address", match.MissingSkills); + List(sb, "Most relevant experience", + match.RelevantExperience.Select(e => e.Subtitle is null ? e.Title : $"{e.Title} ({e.Subtitle})").ToList()); + List(sb, "Most relevant projects", match.RelevantProjects.Select(p => p.Title).ToList()); + } + + return sb.ToString(); + + static void Line(System.Text.StringBuilder sb, string label, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) sb.Append($"\n{label}: {value}"); + } + + static void List(System.Text.StringBuilder sb, string label, IReadOnlyList values) + { + if (values.Count == 0) return; + sb.Append($"\n{label}: {string.Join("; ", values.Take(8))}"); + } } public async Task GenerateAsync(string ownerUserId, int jobApplicationId, string profileText, string candidateName, AiGenerateRequest req, string provider, CancellationToken ct) @@ -67,12 +115,20 @@ public sealed class AiWorkspaceService : IAiWorkspaceService var mode = NormalizeMode(module, req.Mode); var extra = string.IsNullOrWhiteSpace(req.ExtraContext) ? string.Empty : $"\n\nAdditional user context:\n{req.ExtraContext.Trim()}"; + // Interview prep is the module that benefits most from what the workspace already computed: + // asking for likely questions without the requirements, the matched skills and — above all — + // the gaps produces generic output. Everything here is deterministic and already on screen, so + // this adds context, not another AI call. Null when unavailable, and the prompt is unchanged. + var intelligence = module == "interview" && _intelligence is not null + ? await BuildIntelligenceContextAsync(ownerUserId, jobApplicationId, ct) + : string.Empty; + var (instruction, source, title, max) = module switch { "job-analysis" => (JobAnalysisPrompt(), jobText + extra, "Job analysis", 1000), "career-match" => (CareerMatchPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Career match", 1000), "cover-letter" => (CoverLetterPrompt(mode!, candidateName), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", $"Cover letter · {Capitalize(mode!)}", 900), - "interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Interview prep", 1100), + "interview" => (InterviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{intelligence}{extra}", "Interview prep", 1100), "application-review" => (ApplicationReviewPrompt(), $"CANDIDATE PROFILE:\n{profile}\n\nJOB ADVERT:\n{jobText}{extra}", "Application review", 900), _ => throw new ArgumentException($"Unknown AI module '{module}'."), };